Building a Complete Video Streaming Platform: From React Frontend to Node.js Backend with HLS Transcoding

Enthusiastic and dedicated Full Stack Developer with a passion for crafting efficient, user-centric, and innovative web solutions. Since 2019 I’ve been providing high-level support to agencies, startups and freelancing in various positions.
Introduction
In today's digital landscape, video streaming has become a cornerstone of online content delivery. Whether you're building a live streaming platform, an educational course system, or a video-on-demand service, implementing proper video transcoding and streaming is crucial for delivering high-quality content across all devices.
This comprehensive guide walks through building a complete video streaming platform using React for the frontend, Node.js with Hono for the backend, AWS MediaConvert for transcoding, and HLS (HTTP Live Streaming) for adaptive bitrate streaming.
Table of Contents
Architecture Overview
Our video streaming platform follows a modern microservices architecture:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ React App │ │ Node.js API │ │ AWS Services │
│ │ │ │ │ │
│ • Video Player │◄──►│ • Upload API │◄──►│ • S3 Storage │
│ • Event Stream │ │ • Transcoding │ │ • MediaConvert │
│ • User Auth │ │ • Webhooks │ │ • CloudFront │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Frontend Implementation
React Video Player Component
The heart of our frontend is a sophisticated video player that handles both regular MP4 files and HLS streams:
// src/views/concepts/events/EventStream/components/EventVideoPlayer.tsx
import React, { useRef, useEffect, useState } from 'react';
import Hls from 'hls.js';
interface EventVideoPlayerProps {
videoUrl?: string;
hls_presigned_url?: string;
poster?: string;
onError?: (error: string) => void;
}
export const EventVideoPlayer: React.FC<EventVideoPlayerProps> = ({
videoUrl,
hls_presigned_url,
poster,
onError,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Cleanup previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
// Check if the video URL is an HLS stream (.m3u8)
if (Hls.isSupported() && hls_presigned_url) {
// Create Hls instance
hlsRef.current = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
});
hlsRef.current.loadSource(hls_presigned_url);
hlsRef.current.attachMedia(video);
// Handle HLS manifest parsed event
hlsRef.current.on(Hls.Events.MANIFEST_PARSED, () => {
setIsLoading(false);
video.play().catch(console.error);
});
// Handle HLS errors
hlsRef.current.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS Error:', data);
setError('Failed to load HLS stream');
onError?.('HLS stream loading failed');
});
// Handle HLS loading progress
hlsRef.current.on(Hls.Events.MANIFEST_LOADING, () => {
setIsLoading(true);
});
} else if (videoUrl) {
// Fallback to regular video for non-HLS
video.src = videoUrl;
video.addEventListener('loadeddata', () => {
setIsLoading(false);
});
}
// Cleanup function
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
}
};
}, [videoUrl, hls_presigned_url, onError]);
return (
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-75 z-10">
<div className="text-white text-center">
<p className="text-lg font-semibold mb-2">Video Loading Error</p>
<p className="text-sm opacity-75">{error}</p>
</div>
</div>
)}
<video
ref={videoRef}
className="w-full h-full object-contain"
poster={poster}
controls
preload="metadata"
playsInline
/>
</div>
);
};
Event Stream Integration
Our video player integrates seamlessly with event streaming:
// src/views/concepts/events/EventStream/components/EventStream.tsx
import React, { useEffect, useState } from 'react';
import { EventVideoPlayer } from './EventVideoPlayer';
import { ChatBody } from '../../chat/Chat/components/ChatBody';
interface EventStreamProps {
eventId: number;
eventData: {
asset?: {
presignedUrl?: string;
hls_presigned_url?: string;
asset_name: string;
};
host?: {
name: string;
profile_image?: string;
};
};
}
export const EventStream: React.FC<EventStreamProps> = ({ eventId, eventData }) => {
const [isLive, setIsLive] = useState(false);
const [viewerCount, setViewerCount] = useState(0);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-screen">
{/* Video Player Section */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-lg p-4">
<EventVideoPlayer
videoUrl={eventData.asset?.presignedUrl}
hls_presigned_url={eventData.asset?.hls_presigned_url}
poster={eventData.host?.profile_image}
onError={(error) => console.error('Video error:', error)}
/>
{/* Event Info */}
<div className="mt-4">
<h1 className="text-2xl font-bold text-gray-900">
{eventData.asset?.asset_name}
</h1>
<p className="text-gray-600">
Hosted by {eventData.host?.name}
</p>
<div className="flex items-center mt-2">
<span className={`w-3 h-3 rounded-full mr-2 ${
isLive ? 'bg-red-500 animate-pulse' : 'bg-gray-400'
}`}></span>
<span className="text-sm text-gray-600">
{isLive ? 'Live' : 'Offline'} • {viewerCount} viewers
</span>
</div>
</div>
</div>
</div>
{/* Chat Section */}
<div className="lg:col-span-1">
<ChatBody eventId={eventId} />
</div>
</div>
);
};
Backend API Design
Asset Controller
Our backend handles video uploads, transcoding, and streaming through a comprehensive asset controller:
// src/web/controller/asset.ts
import type { Context } from 'hono';
import { logger } from '../../lib/logger.js';
import type { AssetService } from '../../service/asset.js';
import type { EmailService } from '../../service/email.ts';
import type { EventService } from '../../service/event.js';
import type { LeadService } from '../../service/lead.js';
import type { UserService } from '../../service/user.js';
export class AssetController {
private service: AssetService;
private userService: UserService;
private eventService: EventService;
private leadService: LeadService;
private emailService: EmailService;
constructor(
service: AssetService,
userService: UserService,
eventService: EventService,
leadService: LeadService,
emailService: EmailService,
) {
this.service = service;
this.userService = userService;
this.eventService = eventService;
this.leadService = leadService;
this.emailService = emailService;
}
/**
* Creates a new asset with multipart upload support
*/
public createMultipartAsset = async (c: Context) => {
try {
const user = await this.getUser(c);
if (!user) {
return serveBadRequest(c, ERRORS.USER_NOT_FOUND);
}
const body: CreateMultipartAssetBody = await c.req.json();
const { fileName, contentType, assetType, fileSize, duration, partSize } = body;
const result = await this.service.createMultipartAsset(
user.id,
fileName,
contentType,
assetType,
fileSize,
duration,
partSize,
);
return c.json(result);
} catch (error) {
logger.error(error);
return serveInternalServerError(c, error);
}
};
/**
* Completes multipart upload and triggers HLS conversion for videos
*/
public completeMultipartUpload = async (c: Context) => {
try {
const user = await this.getUser(c);
if (!user) {
return serveBadRequest(c, ERRORS.USER_NOT_FOUND);
}
const assetId = Number(c.req.param('id'));
if (!assetId) {
return serveBadRequest(c, ERRORS.ASSET_ID_NOT_FOUND);
}
const body: CompleteMultipartUploadBody = await c.req.json();
const { parts } = body;
await this.service.completeMultipartUpload(assetId, parts);
// Get the asset to check if it's a video that needs HLS conversion
const asset = await this.service.getAsset(assetId);
if (asset && asset.asset_type === 'video') {
try {
// Start HLS conversion automatically
const conversionResult = await this.service.startHlsConversion(assetId);
logger.info('HLS conversion started automatically after upload:', {
assetId: assetId,
jobId: conversionResult.jobId,
status: conversionResult.status,
});
return c.json({
success: true,
message: 'Upload completed and HLS conversion started',
hlsConversion: {
jobId: conversionResult.jobId,
status: conversionResult.status,
},
});
} catch (conversionError) {
logger.error('Failed to start HLS conversion after upload:', conversionError);
return c.json({
success: true,
message: 'Upload completed but HLS conversion failed',
error: 'HLS conversion could not be started',
});
}
}
return c.json({ success: true });
} catch (error) {
logger.error(error);
return serveInternalServerError(c, error);
}
};
/**
* Handles MediaConvert webhook events for HLS conversion progress
*/
public handleHlsConversionWebhook = async (c: Context) => {
try {
const body = (await c.req.json()) as MediaConvertWebhookPayload;
if (body.detail.status === 'COMPLETE') {
// Handle completed job
logger.info({
event: 'MediaConvert job completed',
jobId: body.detail.jobId,
outputGroupDetails: body.detail.outputGroupDetails,
});
// Find the asset associated with this MediaConvert job
const asset = await this.service.findAssetByMediaConvertJobId(body.detail.jobId);
if (!asset) {
logger.warn('No asset found for MediaConvert job ID:', body.detail.jobId);
return serveData(c, {
message: 'HLS conversion completed but no asset found',
jobId: body.detail.jobId,
});
}
// Extract HLS URL from outputGroupDetails
const outputFilePath =
body.detail?.outputGroupDetails?.[0]?.outputDetails?.[0]?.outputFilePaths?.[0];
if (outputFilePath) {
// Update the asset with the HLS URL
await this.service.updateAssetProcessingStatus(asset.id, {
mediaconvert_job_status: 'completed',
mediaconvert_job_progress: 100,
mediaconvert_job_current_phase: 'completed',
processing_status: 'completed',
hls_url: outputFilePath,
});
// Send email notification to host
const host = await this.userService.find(asset.user_id);
if (host) {
await this.emailService.createEmail({
host_id: host.id,
email: host.email,
subject: 'Your video is ready for streaming!',
title: 'HLS Conversion Complete',
subtitle: `${asset.asset_name} is now optimized for adaptive streaming.`,
body: `
Great news! Your video ${asset.asset_name} has finished processing and is now available in HLS format.
This means faster playback, better quality, and seamless streaming across all devices.
You can now use it in your events with confidence.
`,
button_text: 'View Your Video',
button_link: `${process.env.FRONTEND_URL}`,
});
}
logger.info('Asset updated with HLS URL:', {
assetId: asset.id,
hlsUrl: outputFilePath,
});
} else {
logger.warn('No output file path found in MediaConvert response');
}
return serveData(c, {
message: 'HLS conversion completed successfully',
assetId: asset.id,
outputFilePath: outputFilePath,
});
}
if (body.detail.status === 'PROGRESSING') {
// Handle progressing job
const { jobProgress } = body.detail;
logger.info({
event: 'MediaConvert job progressing',
jobId: body.detail.jobId,
currentPhase: jobProgress?.currentPhase,
jobPercentComplete: jobProgress?.jobPercentComplete,
phaseProgress: jobProgress?.phaseProgress,
});
// Find the asset and update processing status
const asset = await this.service.findAssetByMediaConvertJobId(body.detail.jobId);
if (asset) {
// Update the asset with progressing status
await this.service.updateAssetProcessingStatus(asset.id, {
mediaconvert_job_progress: jobProgress?.jobPercentComplete,
mediaconvert_job_current_phase: jobProgress?.currentPhase,
processing_status: 'processing',
});
}
return serveData(c, {
message: 'HLS conversion progressing',
jobId: body.detail.jobId,
progress: jobProgress?.jobPercentComplete,
currentPhase: jobProgress?.currentPhase,
});
}
return serveData(c, {
message: 'HLS conversion webhook received',
body: body,
});
} catch (error) {
logger.error('Error processing MediaConvert webhook:', error);
return serveInternalServerError(c, error);
}
};
}
Video Upload & Processing
Multipart Upload Service
For handling large video files, we implement multipart uploads:
// src/service/asset.ts
export class AssetService {
private repository: AssetRepository;
private s3Service: S3Service;
constructor(repository: AssetRepository, s3Service: S3Service) {
this.repository = repository;
this.s3Service = s3Service;
}
/**
* Creates a multipart asset with presigned URLs for each part
*/
public async createMultipartAsset(
userId: number,
fileName: string,
contentType: string,
assetType: string,
fileSize: number,
duration?: number,
partSize: number = 5 * 1024 * 1024, // 5MB default
) {
// Create asset record
const asset = await this.repository.create({
user_id: userId,
asset_name: fileName,
asset_type: assetType,
asset_url: '', // Will be set after upload completion
file_size: fileSize,
duration,
processing_status: 'uploading',
});
// Calculate number of parts
const numParts = Math.ceil(fileSize / partSize);
const key = `assets/${assetType}/${asset.id}/${fileName}`;
// Initiate multipart upload
const { uploadId, url } = await this.s3Service.initiateMultipartUpload(key, contentType);
// Generate presigned URLs for each part
const presignedUrls = [];
for (let i = 1; i <= numParts; i++) {
const presignedUrl = await this.s3Service.generateMultipartPresignedUrl(key, uploadId, i);
presignedUrls.push({
partNumber: i,
presignedUrl,
});
}
// Update asset with upload details
await this.repository.update(asset.id, {
asset_url: url,
upload_id: uploadId,
s3_key: key,
});
return {
assetId: asset.id,
uploadId,
presignedUrls,
numParts,
partSize,
};
}
/**
* Completes multipart upload and triggers HLS conversion for videos
*/
public async completeMultipartUpload(
assetId: number,
parts: Array<{ ETag: string; PartNumber: number }>,
) {
const asset = await this.repository.findById(assetId);
if (!asset) {
throw new Error('Asset not found');
}
if (!asset.upload_id || !asset.s3_key) {
throw new Error('Asset upload not initiated');
}
// Complete multipart upload
await this.s3Service.completeMultipartUpload(asset.s3_key, asset.upload_id, parts);
// Update asset status
await this.repository.update(assetId, {
processing_status: 'uploaded',
upload_id: null, // Clear upload ID after completion
});
return { success: true };
}
}
AWS MediaConvert Integration
S3 Service with MediaConvert
// src/service/s3.ts
export class S3Service {
private client: S3Client;
private mediaConvertClient: MediaConvert;
private bucket: string;
constructor() {
this.client = new S3Client({
region: env.AWS_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY,
secretAccessKey: env.AWS_SECRET_KEY,
},
});
this.mediaConvertClient = new MediaConvert({
region: env.AWS_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY,
secretAccessKey: env.AWS_SECRET_KEY,
},
});
this.bucket = env.S3_BUCKET_NAME;
}
/**
* Creates a MediaConvert job for HLS transcoding
*/
async createMediaConvertJob(
s3Key: string,
roleArn?: string,
jobTemplate?: string,
): Promise<{ jobId: string; status: string }> {
const inputUrl = `s3://${this.bucket}/${s3Key}`;
const outputKey = s3Key.replace(/\.(mp4|mov|avi|mkv)$/i, '');
const outputUrl = `s3://${this.bucket}/assets/videos_hls/${outputKey}`;
const jobSettings = {
TimecodeConfig: {
Source: 'ZEROBASED',
},
OutputGroups: [
{
Name: 'HLS',
OutputGroupSettings: {
HlsGroupSettings: {
SegmentLength: 10,
MinSegmentLength: 0,
DirectoryStructure: 'SINGLE_DIRECTORY',
ManifestDurationFormat: 'INTEGER',
StreamInfResolution: 'INCLUDE',
ClientCache: 'ENABLED',
CodecSpecification: 'RFC_4281',
OutputSelection: 'MANIFESTS_AND_SEGMENTS',
ProgramNum: 1,
ProgramDateTime: 'EXCLUDE',
ProgramDateTimePeriod: 600,
TimedMetadataBehavior: 'NO_PASSTHROUGH',
TimedMetadataPid: 502,
PcrPid: 480,
PmtPid: 481,
VideoPid: 482,
AudioPids: [
483,
484,
485,
486,
487,
488,
489,
490,
491,
],
AudioPid: 482,
},
},
Outputs: [
{
NameModifier: '_1080p',
VideoDescription: {
Width: 1920,
Height: 1080,
CodecSettings: {
H264Settings: {
Profile: 'MAIN',
RateControlMode: 'QVBR',
QvbrSettings: {
QvbrQualityLevel: 8,
},
MaxBitrate: 5000000,
AdaptiveQuantization: 'HIGH',
EntropyEncoding: 'CABAC',
Syntax: 'DEFAULT',
GopSize: 90,
GopSizeUnits: 'FRAMES',
ParControl: 'INITIALIZE_FROM_SOURCE',
NumberReferenceFrames: 3,
SlowPal: 'DISABLED',
SpatialAq: 'ENABLED',
TemporalAq: 'ENABLED',
FlickerAq: 'ENABLED',
GopClosedCadence: 1,
GopBReference: 'DISABLED',
GopBReferenceSettings: {
GopBReference: 'DISABLED',
},
GopCReference: 'DISABLED',
GopCReferenceSettings: {
GopCReference: 'DISABLED',
},
AdaptiveGopSize: 'ENABLED',
AdaptiveGopReference: 'ENABLED',
AdaptiveGopBReference: 'ENABLED',
AdaptiveGopCReference: 'ENABLED',
AdaptiveGopClosedCadence: 'ENABLED',
AdaptiveGopOpenCadence: 'ENABLED',
AdaptiveGopReference: 'ENABLED',
AdaptiveGopBReference: 'ENABLED',
AdaptiveGopCReference: 'ENABLED',
AdaptiveGopClosedCadence: 'ENABLED',
AdaptiveGopOpenCadence: 'ENABLED',
AdaptiveGopReference: 'ENABLED',
AdaptiveGopBReference: 'ENABLED',
AdaptiveGopCReference: 'ENABLED',
AdaptiveGopClosedCadence: 'ENABLED',
AdaptiveGopOpenCadence: 'ENABLED',
AdaptiveGopReference: 'ENABLED',
AdaptiveGopBReference: 'ENABLED',
AdaptiveGopCReference: 'ENABLED',
AdaptiveGopClosedCadence: 'ENABLED',
AdaptiveGopOpenCadence: 'ENABLED',
AdaptiveGopReference: 'ENABLED',
AdaptiveGopBReference: 'ENABLED',
AdaptiveGopCReference: 'ENABLED',
AdaptiveGopClosedCadence: 'ENABLED',
AdaptiveGopOpenCadence: 'ENABLED',
},
},
},
AudioDescriptions: [
{
CodecSettings: {
AacSettings: {
Bitrate: 192000,
CodingMode: 'CODING_MODE_2_0',
SampleRate: 48000,
},
},
},
],
ContainerSettings: {
Container: 'M3U8',
M3u8Settings: {
AudioFramesPerPes: 4,
PcrControl: 'PCR_EVERY_PES_PACKET',
PmtPid: 480,
PrivateMetadataPid: 503,
ProgramNumber: 1,
PatInterval: 0,
PmtInterval: 0,
Scte35Source: 'NONE',
TimedMetadata: 'NONE',
VideoPid: 481,
},
},
},
],
},
],
Inputs: [
{
FileInput: inputUrl,
AudioSelectors: {
'Audio Selector 1': {
DefaultSelection: 'DEFAULT',
},
},
VideoSelector: {},
TimecodeSource: 'ZEROBASED',
},
],
};
const params = {
Role: roleArn || env.AWS_MEDIACONVERT_ROLE_ARN,
Settings: jobSettings,
UserMetadata: {
assetId: s3Key,
},
Queue: env.AWS_MEDIACONVERT_QUEUE_ARN,
JobTemplate: jobTemplate || env.AWS_MEDIACONVERT_JOB_TEMPLATE,
OutputGroupDetails: [
{
OutputGroupType: 'HLS_GROUP',
OutputDetails: [
{
OutputFilePaths: [outputUrl],
},
],
},
],
};
const command = new CreateJobCommand(params);
const response = await this.mediaConvertClient.send(command);
return {
jobId: response.Job?.Id,
status: response.Job?.Status,
};
}
}
HLS Streaming Implementation
Once transcoding is complete, AWS MediaConvert outputs a manifest file (.m3u8) and multiple segment files (.ts) to your S3 bucket. The backend stores the manifest URL in the asset record and provides a presigned or public URL to the frontend.
The React player then loads the manifest, and the HLS.js library fetches the segments as needed for smooth, adaptive playback.
S3 Bucket Configuration
To allow HLS streaming, you must ensure that the .m3u8 and .ts files are accessible to your users. The recommended approach is to update your S3 bucket policy to allow public read access only to these files:
{
"Sid": "AllowHlsFiles",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": [
"arn:aws:s3:::xxxxx/assets/videos_hls/*.m3u8",
"arn:aws:s3:::xxxxxx/assets/videos_hls/*.ts"
]
}
This ensures your video segments are streamable, while other assets remain protected.
Webhook Handling
Your backend should listen for AWS MediaConvert webhook events to update asset status and notify users when transcoding is complete. This enables real-time feedback and email notifications, improving user experience.
Frontend Video Player
The React video player uses the HLS manifest URL provided by the backend. If the video is still processing, you can show a loading spinner or a message. Once the manifest is available, the player automatically starts streaming.
Error Handling & Monitoring
Frontend: Display user-friendly error messages if the video fails to load.
Backend: Log all transcoding and upload errors, and notify admins if needed.
Monitoring: Use AWS CloudWatch and custom logging for end-to-end observability.
Performance Optimization
Use multipart uploads for large files.
Leverage CloudFront CDN for global video delivery.
Optimize MediaConvert settings for your audience (bitrate, resolution, etc.).
Security Considerations
Only allow public access to HLS files, not original uploads.
Use presigned URLs for sensitive assets.
Validate all uploads and user actions on the backend.
Conclusion
By combining a modern React frontend, a robust Node.js backend, AWS MediaConvert for transcoding, and HLS for adaptive streaming, you can deliver a seamless, scalable video experience to your users. With the right S3 bucket policy and webhook integration, your platform will be secure, performant, and user-friendly.
Ready to build your own streaming platform?
Start with this architecture, and you'll be well on your way to delivering high-quality video to any device, anywhere in the world!




