Adding Subtitles to Video Content with Redwood


Sometimes you just want to watch videos with the subtitles on. It might be because you want to mute the video and just read what's happening. Or maybe you're using subtitles to help teach yourself a different language.

These are a few reasons why it's important to know how to add subtitles to videos in your web apps. It's also important to include subtitles for accessibility purposes so that everyone can get the information from your videos. So we're going to build a Redwood app that uses Cloudinary to display video subtitles using .srt files.

Setting up the project

To get started we'll initialize a new Redwood project with the following command:

1$ yarn create redwood-app --typescript video-subtitles

This command generates a functioning full-stack app. There are a number of files and folders, but we'll mainly work in the api and web directories. These contain the code for the front-end and back-end respectively. This project uses the --typescript flag so we'll be able to add types for everything from the beginning.

Let's start with the back-end and set up our database.

Writing the database schema

Open the schema.prisma file in api > db. This is where we'll set up our database connection and the schema for all of the tables. The first thing we'll do is update the provider value from sqlite to postgresql. This is how we tell the app to connect to our Postgres instance.

Next, we need to update the DATABASE_URL environment variable in the .env file to your local Postgres. Your connection string might look similar to this:


Don't worry if you don't have a database called subtitles. When we run the migration, this will be created automatically.

Now we can define the model for the videos we'll upload. You can delete the UserExample model and replace it with the following code:

1model Video {
2 id Int @id @default(autoincrement())
3 url String
4 srtFile String

This defines the table and the columns we'll need for the database to store the videos and their subtitle files. We're going to do one more thing before doing the database migration.

Seeding the database with data

We'll seed the database with a subtitle file to have data to get started with. Check out the scripts folder and you'll see a file called seed.ts. This is where we'll add the data for the video. You can download an example of a .srt file here and save it to the root of your project.

If you don't have a Cloudinary account, this is the perfect time to make one so that you can upload a video and the subtitle file. Once you have these two files uploaded to your Cloudinary account, then you can update the code in seed.ts to match the Video schema we have.

1import type { Prisma } from '@prisma/client'
2import { db } from 'api/src/lib/db'
4export default async () => {
5 try {
6 const data: Prisma.VideoCreateInput['data'][] = [
7 { url: `${cloudName}/video/upload/v1606580790/elephant_herd.mp4`, srtFile: `${cloudName}/raw/upload/v1643650731/` }
8 ]
9 console.log(
10 "\nUsing the default './scripts/seed.{js,ts}' template\nEdit the file to add seed data\n"
11 )
13 Promise.all(
14 (data: Prisma.VideoCreateInput['data']) => {
15 const record = await{ data })
16 console.log(record)
17 })
18 )
19 } catch (error) {
20 console.warn('Please define your seed data.')
21 console.error(error)
22 }

An important thing to note in this code is that you need to update the url and srtFile values to use the links from your Cloudinary account. Since this is the only data we're going to seed in the database, we can go ahead and run the migration with the following command:

1$ yarn redwood prisma migrate dev

This command creates the subtitles database if it doesn't exist, creates the Video table schema, and adds the seed data to the table. If you check your local Postgres instance, you should see a record in the Video table already.

Now that we've done everything we need to with the database setup, let's move on to the GraphQL server.

Making the GraphQL types and queries

One of my favorite things about Redwood is that once you have all of your database schema defined, there are a few commands that make it super easy to generate all of the types, queries, and mutations to handle all of the CRUD operations in GraphQL. So let's run the following command to generate all the things we need on the back-end.

1$ yarn redwood generate sdl video --crud

This one command has just generated all of the files and code we need in order to work with our data on the front-end. Take a look in api > src > graphql and you'll see the video.sdl.ts file. This has all of the types we need in GraphQL for the video data based on how we defined the database schema. Now look over in api > src > services > videos. This has the file with all of the queries and mutations we need to execute actions on the database.

That's all of the GraphQL requirements met with a single command. Now we can turn our attention over to the front-end where most of the interesting work is happening because we'll be using Cloudinary's video uploader and their upload API.

Creating the video page

Moving over to the front-end, take a look in the web > src directory. This is where the rest of our work will take place. We'll start by adding the package we need to use the Cloudinary widget. Go to the web directory in your terminal and run the following command:

1$ yarn add react-cloudinary-upload-widget

Then we'll make a new page that will handle the uploads for both our videos and subtitle files and it will also display the videos we've uploaded before with the subtitles enabled. To make this new page, run the following command:

1$ yarn redwood generate page video

This will create a new page component in web > src > pages > VideoPage and it will update the Routes.tsx file to include this new page. We're going to start by adding the video upload functionality to this page.

Uploading the videos to Cloudinary

So open the VideoPage.tsx file and take a look at the boilerplate code. We'll use the Cloudinary upload widget to handle our videos and we'll keep a record of the URL for display later. You can delete the code below the <h1> element inside the component. That will leave your file looking like this.

1import { Link, routes } from '@redwoodjs/router'
2import { MetaTags } from '@redwoodjs/web'
4const VideoPage = () => {
5 return (
6 <>
7 <MetaTags title="Video" description="Video page" />
9 <h1>VideoPage</h1>
10 </>
11 )
14export default VideoPage

We'll replace the unused import with the following import. This will give us access to that widget and all of its functionality.

1import { WidgetLoader, Widget } from 'react-cloudinary-upload-widget'

Before we jump into using this widget component, let's define a couple more environment variables. Open the .env file and add the following values below your DATABASE_URL. These will protect your sensitive information from being visible directly in the code. You can find the values for these variables in your Cloudinary dashboard.


Now we can turn back to the VideoPage component. Let's add an empty upload function that we'll update later and the upload widget itself.

1import { WidgetLoader, Widget } from 'react-cloudinary-upload-widget'
2import { MetaTags } from '@redwoodjs/web'
4const VideoPage = () => {
5 function uploadVideoFn() {
7 }
9 return (
10 <>
11 <MetaTags title="Video" description="Video page" />
13 <h1>VideoPage</h1>
15 <WidgetLoader />
16 <Widget
17 sources={['local', 'camera']}
18 cloudName={process.env.CLOUD_NAME}
19 uploadPreset={process.env.UPLOAD_PRESET}
20 buttonText={'Add Video'}
21 style={{
22 color: 'white',
23 border: 'none',
24 width: '120px',
25 backgroundColor: 'green',
26 borderRadius: '4px',
27 height: '25px',
28 }}
29 folder={'subtitled_videos'}
30 onSuccess={uploadVideoFn}
31 />
32 </>
33 )
36export default VideoPage

If you run the app now, you'll be able to see the uploader button and clicking it will open the widget. You can run the app with the following command. Make sure you're at the root of the project in your terminal.

1$ yarn redwood dev

Going to the video route will show you views similar to these.

Now we need to update that uploadVideoFn. This is where we'll save the uploaded video's URL until we have the subtitle file as well. Add this code to the placeholder function.

1function uploadVideoFn(results: CloudinaryResult) {
2 const imageInfo =
4 setUrl(imageInfo.url)

There are a couple of things we need to do before you can use this function. First, let's add a new import to use the state hook in React. So add this to the top of the import list.

1import { useState } from 'react'

Next, add the type definition for the data we'll be using from the Cloudinary response below the import statements.

1interface CloudinaryResult {
2 info: {
3 url: string
4 }

Then right inside of the component, above the uploadVideoFn, add the following states. We'll only use one for now, but we'll need the other really soon.

1const [url, setUrl] = useState<string>("")
2const [srtFile, setSrtFile] = useState<string>("")

The video uploader is finished now! You can test it out by uploading a video in the browser and checking the state of the app in the developer tools. Now we can move on to the subtitle uploader.

Uploading the subtitles file

We'll need a way for users to upload new subtitle files along with their videos so we'll add a button with this functionality and it'll make a call to the Cloudinary API. This time, let's start by defining the uploadSubtitleFn before we create the file upload input. So let's add this function below uploadVideoFn.

1async function uploadSubtitleFn(e) {
2 const uploadApi = `${process.env.CLOUD_NAME}/raw/upload`
4 const formData = new FormData()
5 formData.append('file', e.currentTarget.value)
6 formData.append('upload_preset', process.env.CLOUD_NAME)
8 const cloudinaryRes = await fetch(uploadApi, {
9 method: 'POST',
10 body: formData,
11 })
13 setSrtFile(cloudinaryRes.url)

Then we can add the file input element that will trigger this function whenever a new .srt file gets uploaded. This element will go just below the <Widget>.

1<input type="file" onChange={uploadSubtitleFn} />

That's all for the upload functionality. The last thing we need to do is add a button that will handle the creation of a database record with the URLs for the video and subtitles.

Saving the record

We need to update one of the import statements we have. Add the two new objects to this import.

1import { MetaTags, useMutation, useQuery } from '@redwoodjs/web'

Next, let's define the mutation we need to create a new video record. This will go right below the import statements.

2 mutation CreateVideoMutation($input: CreateVideoInput!) {
3 createVideo(input: $input) {
4 id
5 url
6 srtFile
7 }
8 }

Then we'll define the method we'll use when we're ready to submit the video. This will go inside the component, right above the useState declarations.

1const [createVideo] = useMutation(CREATE_VIDEO_MUTATION)

Now we can add one more function that will let us handle video record submissions. This will go just below the uploadSubtitleFn.

1function createVideoRecord() {
2 const input = {
3 url: url,
4 srtFile: srtFile,
5 }
7 createVideo({ variables: { input } })

This calls the GraphQL mutation we created on the back-end earlier and adds the new info to the table. All that's left is adding the button. We'll put that below the file input.

1<button style={{ display: 'block' }} onClick={createVideoRecord}>Make this video record</button>

Whenever this button is clicked, it will attempt to create a new video record so make sure that your states have valid values. This is a great place to practice implementing input validation and error handling. If you run your app at this point with yarn rw dev, then you should see something like this.

All that's left is the query and displaying of the videos we upload with their subtitles!

Displaying the video with subtitles

There's a GraphQL query that we need to define to get all of the video data. We'll do that right above the mutation definition.

1const GET_VIDEOS = gql`
2 query {
3 videos {
4 url
5 srtFile
6 }
7 }

Then we'll add a few objects from the useQuery hook we use to get the data back. Add this line right above the mutation method inside the component.

2const { data, loading } = useQuery(GET_VIDEOS)
3const [createVideo] = useMutation(CREATE_VIDEO_MUTATION)
4const [url, setUrl] = useState<string>("")

Any time we're working with loading data on the front-end, it's a great practice to have something displayed while the data is loading to prevent the app from crashing. That's where the loading value comes in. It tells us if the video data is still being fetched. We'll add a simple loading element above the return statement.

1if (loading) {
2 return <div>Loading...</div>

Lastly, we need to display the videos with their subtitles. To do that, we'll need to add some <video> elements for every video returned from the query. This will go below the video record creation button.

1<div style={{ display: 'block' }}>
2 {data?.videos &&
3 data? => (
4 <video
5 controls
6 key={}
7 style={{ height: '500px', width: '500px' }}
8 >
9 <source src={``}></source>
10 </video>
11 ))
12 }

We're using a custom Cloudinary URL with the names of the video and subtitle files we uploaded together. When you reload the page with your app running, you should see something like this.

Now we're done!

Finished code

You might want to take a look at the complete code over here in the video-subtitles folder of this repo.

Or you can check out some of the front-end functionality in this Code Sandbox.


Having subtitles on videos is a crucial step for keeping content online accessible to everyone. The .srt are well worth the time to learn how to write. It's just a specific format you have to follow in a text file. So hopefully this made everything look easy to you and now you can add subtitles to all the videos!


Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.