Streaming Fundamentals And Project Scope
Video playback over the web relies on streams rather than monolithic file transfers. The browser requests a video in segments, which is why playback can stall on slow connections—the player can only render the chunks it has already received. This article walks through building a complete video streaming application with a Node.js and Express backend and a Nuxt.js frontend. The server handles video fetching, chunked streaming, thumbnail generation, and caption delivery, while the Nuxt client provides both a video listing page and a dedicated player view.
This project is aimed at developers comfortable with HTML, CSS, JavaScript, Node/Express, and Vue who want hands-on experience. You'll need a text editor, a modern browser, FFmpeg installed locally, Node.js (ideally managed via nvm), and the ability to clone the project source code from GitHub.
Backend Setup And Initial Routes
Begin by creating a project directory and navigating into it
mkdir streaming-app
Create a backend folder inside your project. This will house the server application
cd streaming-app
mkdir backend
Initialize a package.json file within the backend directory
cd backend
npm init -y
Install the necessary dependencies: nodemon for automatic server restarts during development, express for route handling, and cors to permit cross-origin requests between your client and server which will run on different ports.
Create an assets folder inside the backend to store the video files for streaming
mkdir assets
Add a sample .mp4 file to this folder and name it video1. You can find suitable sample videos in the tutorial's GitHub repository.
Now create an app.js file to set up the core of the server
const express = require('express');
const fs = require('fs');
const cors = require('cors');
const path = require('path');
const app = express();
app.use(cors())
This configuration uses the built-in fs module for file operations and the path module for handling file and directory paths. Next, we'll define a simple route to serve a video file directly
// add after 'const app = express();'
app.get('/video', (req, res) => {
res.sendFile('assets/video1.mp4', { root: __dirname });
});
This route responds to requests by sending the video1.mp4 file. The server is configured to listen on port 3000
// add to end of app.js file
app.listen(5000, () => {
console.log('Listening on port 5000!')
});
Add a script to your package.json that starts the server with nodemon
"scripts": {
"start": "nodemon app.js"
},
Run the server with this command
npm run start
If the terminal shows Listening on port 3000!, the setup is correct. Visitting https://localhost:5000/video in your browser should play the video.
Frontend API Requirements And Video Data
The frontend needs a set of specific endpoints from your backend. First, a /videos route should return an array of mock video data. Second, a /video/:id/data endpoint that provides metadata for a single video, used by the player page. Third, a /video/:id route for the actual streaming of a video file.
For this demonstration, we'll use a static array of objects to serve as our video database. A production application would likely fetch this data from a real database, but this mock approach is simpler and keeps the tutorial focused on the streaming mechanics. Create a file named mockdata.js in the backend folder
const allVideos = [
{
id: "tom and jerry",
poster: 'https://image.tmdb.org/t/p/w500/fev8UFNFFYsD5q7AcYS8LyTzqwl.jpg',
duration: '3 mins',
name: 'Tom & Jerry'
},
{
id: "soul",
poster: 'https://image.tmdb.org/t/p/w500/kf456ZqeC45XTvo6W9pW5clYKfQ.jpg',
duration: '4 mins',
name: 'Soul'
},
{
id: "outside the wire",
poster: 'https://image.tmdb.org/t/p/w500/lOSdUkGQmbAl5JQ3QoHqBZUbZhC.jpg',
duration: '2 mins',
name: 'Outside the wire'
},
];
module.exports = allVideos
As you can see, each object represents a video and includes a poster attribute pointing to a link for a poster image. Since all frontend routes are prefixed with /videos, we'll organize our routes in a dedicated folder. Create a routes directory and add a Video.js file within it
const express = require('express')
const router = express.Router()
We'll import mockData.js into this file and set up a route to serve our list of videos
const express = require('express')
const router = express.Router()
const videos = require('../mockData')
// get list of videos
router.get('/', (req,res)=>{
res.json(videos)
})
module.exports = router;
With this in place, the data array will be returned as JSON—testable at https://localhost:3000/videos.
Dynamic Video Metadata And Streaming
To fetch data for a specific video, we'll use the id from the route parameters. Add a new route in Video.js to handle this
// make request for a particular video
router.get('/:id/data', (req,res)=> {
const id = parseInt(req.params.id, 10)
res.json(videos[id])
})
The code extracts the id, converts it to an integer, finds a matching object in the videos array, and sends it back to the client.
The /video route we created earlier in app.js sends the whole file. To implement proper streaming, we need to serve videos in chunks dynamically based on the id. First, remove the /video route from app.js.
Now, add the three example videos from the provided source code into your assets/ directory, ensuring the filenames correspond to the id values in the videos array. Then, create the streaming route in Video.js
router.get('/video/:id', (req, res) => {
const videoPath = `assets/${req.params.id}.mp4`;
const videoStat = fs.statSync(videoPath);
const fileSize = videoStat.size;
const videoRange = req.headers.range;
if (videoRange) {
const parts = videoRange.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
} else {
const head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
};
res.writeHead(200, head);
fs.createReadStream(videoPath).pipe(res);
}
});
Navigate to https://localhost:5000/videos/video/outside-the-wire to see this route in action, serving the video to the browser.
Understanding The Streaming Logic
Here's a breakdown of the streaming code
const videoPath = `assets/${req.params.id}.mp4`;
const videoStat = fs.statSync(videoPath);
const fileSize = videoStat.size;
const videoRange = req.headers.range;
The route first gets the id via req.params.id to construct the videoPath. It then reads the fileSize using the fs module. A crucial aspect is the range header sent by the browser, which tells the server which part of the video to send next. Some browsers don't send this header on the first request. For those, we have an else block to handle the initial request
else {
const head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
};
res.writeHead(200, head);
fs.createReadStream(path).pipe(res);
}
Subsequent requests, which include a range, are processed in the main if block
if (videoRange) {
const parts = videoRange.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
}
This block creates a read stream for the specified chunk using the start and end range values. The Content-Length header is set to the size of the chunk being sent. Critically, it uses HTTP status code 206, indicating partial content. This signals to the browser to keep requesting subsequent chunks until the entire video is received.
Handling Unstable Connections
On slow networks, the I/O source will request a pause in data flow until the client is ready for more—a mechanism known as back-pressure. This streaming implementation can easily be extended to handle such scenarios
const start = parseInt(parts[0], 10);
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1;
const chunksize = (end-start) + 1;
const file = fs.createReadStream(videoPath, {start, end});
A ReadStream efficiently serves the video data in sequential chunks
const head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
};
res.writeHead(206, head);
file.pipe(res);
The response header's Content-Range varies for each request to fetch the next chunk, with content-length dictating the size of the current chunk. We explicitly state the Content-Type as mp4. The HTTP header status is written to 206, ensuring only the requested chunk is returned in the response.
Adding Video Captions
A standard WebVTT (.vtt) caption file includes the spoken text along with timestamps indicating when each line should appear
WEBVTT
00:00:00.200 --> 00:00:01.000
Creating a tutorial can be very
00:00:01.500 --> 00:00:04.300
fun to do.
Rather than creating these files, you can download pre-made caption files from the captions subfolder in the assets directory of the project's repository. Create a new route to serve these caption files to your videos
router.get('/video/:id/caption', (req, res) => res.sendFile(`assets/captions/${req.params.id}.vtt`, { root: __dirname }));
Frontend Scaffolding With Nuxt.js
With the backend prepared, we can now build the client application. Ensure you have the Vue CLI installed globally (npm install -g @vue/cli) before proceeding.
At the root of your project, create a folder for the frontend application
mkdir frontend
cd frontend
Inside this folder, initialize a package.json with the necessary configuration
{
"name": "my-app",
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"generate": "nuxt generate",
"start": "nuxt start"
}
}
Install the Nuxt.js framework
npm add nuxt
You can start the Nuxt development server with this command
npm run dev
Organizing The Nuxt File Structure
Nuxt uses a convention-based structure to organize application code. Create a layouts folder to define the app's main chrome layout— such as navigation and footer—visible across all pages. Add a default.vue file for this purpose
mkdir layouts
cd layouts
touch default.vue
Next, create a components folder for reusable parts. This project requires two: a NavBar component and a custom video component for displaying individual items
mkdir components
cd components
touch NavBar.vue
touch Video.vue
Finally, set up a pages folder. In Nuxt, this directory structure powers file-based routing. We'll use a Home page to list all videos and a dynamic page for the video player that links directly to a selected video's id.
With these folders and files in place, your frontend directory structure should reflect the setup shown here
mkdir pages
cd pages
touch index.vue
mkdir player
cd player
touch _name.vue
|-frontend
|-components
|-NavBar.vue
|-Video.vue
|-layouts
|-default.vue
|-pages
|-index.vue
|-player
|-_name.vue
|-package.json
|-yarn.lock
Frontend Wiring: Components and Layouts
To bring the interface together, we first create a NavBar.vue component. It renders an h1 with the text Streaming App, styled minimally for a clean header.
Next, we import this component into the default.vue layout. The layout places NavBar above the <nuxt /> tag, which is where any page component will be injected into the view.
On the homepage (index.vue), we make a request to https://localhost:5000/videos to retrieve the full list of videos from our Express server. The response is passed as a prop down to a video.vue component, which we also import into the page at this stage.
Rendering the Video List
Inside video.vue, we declare a prop to receive the video data. Once the data arrives, Vue’s v-for directive iterates over every item, displaying the metadata for each video. Basic CSS is included in this component to keep the list readable and visually consistent.
Notice that each list item is wrapped in a NuxtLink with a dynamic route pointing to /player/video.id. This link is the trigger for starting playback when the user selects a video.
Dynamic Player Route
The streaming behavior relies on Nuxt’s dynamic route naming convention using _name.vue. When a user clicks one of the video links, Nuxt maps that URL to this dynamic route.
Inside the player page’s component, we instantiate a <video> element and set its source URL to our streaming endpoint. The key is to append the correct video identifier dynamically. We access the captured route parameter with this.$route.params.name, which gives us the exact video ID from the link that was clicked.
This mechanism ensures that each click loads the player for the selected video, and clicking any thumbnail leads to the corresponding streaming session.
Including the Caption Track
To display subtitles, we ensure that every .vtt file inside the captions folder uses the same filename as the video’s id. Then we update the video element by adding a <track> tag that points to the caption file for that specific video.
One crucial detail is adding crossOrigin="anonymous" to the video element. Without this attribute, the browser will reject the caption request due to cross-origin rules. After refreshing the page, captions render correctly over the playback.
Architectural Considerations for Streaming at Scale
Building a production-strength streaming service—think Twitch, Hulu, or Netflix—introduces several technical demands that go beyond a basic demo:
- Data and processing pipeline
Streaming requires a robust backend capable of handling massive concurrent requests. High-performing servers and low-latency responses are non-negotiable; downtime directly translates to user churn. - Caching strategy
To reduce load and speed up deliveries, implement caching layers and distributed storage systems. Common options here include Cassandra, Amazon S3, and AWS SimpleDB. - User geography and distribution
Your content delivery infrastructure must account for where users are located. Distribution networks and regional edge servers help minimize buffering and improve the overall experience globally.
Summary
This walkthrough covered the full stack for a video streaming application. On the backend, Node.js and Express serve streaming endpoints, dynamic captions, and metadata for the video catalog. On the frontend, Nuxt.js consumes those APIs to present a navigable, functional player interface.
Nuxt adds a notable advantage in development speed: its file-based routing and project structure reduce boilerplate and enforce clean architecture, making it a solid choice for media-heavy apps. For further reference, the Nuxt.js guide and the complete project source are available online. MDN’s documentation on adding captions to HTML5 video also provides deeper insight into handling subtitle tracks.



