Render an MP4 from a Next.js API route (real example)
POST a JSON payload, get back an MP4. The route handler, the template, and the Vercel deploy notes.
The pattern: a Next.js API route accepts a JSON payload, renders an HTML template, encodes it to MP4, and streams the result back. The end-user gets a personalized video; the team gets a single deployment artifact.
Here is the real route I run in production, the template it renders, and the four edge cases that cost us a week of debugging.
The route handler
A Route Handler in Next.js App Router. Lives at app/api/render/route.ts. Takes a POST, returns a video/mp4 response.
// app/api/render/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { renderHtmlToMp4 } from '@hyperframes/sdk';
export const runtime = 'nodejs';
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const payload = await req.json();
const html = buildTemplate(payload);
const mp4 = await renderHtmlToMp4(html, {
width: 1920, height: 1080, duration: 6, fps: 30,
});
return new NextResponse(mp4, {
headers: { 'content-type': 'video/mp4' },
});
}The runtime = 'nodejs' is non-negotiable — the rendering uses headless Chromium, which does not run in Edge. The maxDuration = 60 covers the slowest realistic render (six seconds at 1080p takes ~12s on warm infrastructure).
The template
buildTemplate(payload) returns an HTML string. The trick is to keep the template as plain HTML with {{$VAR}} placeholders, not JSX:
function buildTemplate(p: { name: string; metric: string; delta: number }) {
return `<!doctype html>
<html><head><style>
body { margin: 0; background: #f6f5f1; height: 100vh; display: grid; place-items: center; font-family: ui-sans-serif, system-ui; }
.card { padding: 64px; background: white; border-radius: 24px; box-shadow: 0 30px 80px rgba(0,0,0,.1); }
.hi { font-size: 24px; color: #6b6862; letter-spacing: .2em; text-transform: uppercase; }
.name { font-size: 88px; font-weight: 800; margin: 12px 0; }
.delta { font-size: 72px; font-weight: 800; color: #ff3b1f; font-variant-numeric: tabular-nums; }
</style></head>
<body><div class="card">
<div class="hi">Personalized for</div>
<div class="name">Hi ${escapeHtml(p.name)}.</div>
<div>Your ${escapeHtml(p.metric)} is up
<span class="delta" id="d">0%</span>
</div>
</div>
<script>
var t=0; var T=${p.delta};
addEventListener('hf-seek',function(e){ t=e.detail.time;
var u=Math.min(1, t/2); var e2=1-Math.pow(1-u,3);
document.getElementById('d').textContent='+'+(T*e2).toFixed(1)+'%';
});
</script></body></html>`;
}Two things to notice:
escapeHtml. Ifp.namecontains<script>, you want it as text, not code. Use a real escape utility (hepackage, or your framework's built-in).addEventListener('hf-seek', ...). The HyperFrames renderer dispatcheshf-seekevents at each frame's time. The template is a pure function oft— nosetInterval, no animation loops. Frame N looks the same on every render.
The complete picture
import { NextRequest, NextResponse } from 'next/server';
import { renderHtmlToMp4 } from '@hyperframes/sdk';
export const runtime = 'nodejs';
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const payload = await req.json();
const html = buildTemplate(payload);
const mp4 = await renderHtmlToMp4(html, {
width: 1920, height: 1080, duration: 6, fps: 30,
});
return new NextResponse(mp4, {
headers: { 'content-type': 'video/mp4' },
});
}The four edge cases
The bugs that cost us a week:
1. Fonts
Custom fonts are async. Browsers will paint the page before the font loads, then re-paint when it arrives. The renderer captures frames; if it captures before the font loads, the MP4 has fallback typography.
The fix: document.fonts.ready before the first frame.
<script>
document.fonts.ready.then(() => {
window.__hf_ready__ = true;
});
</script>The HyperFrames SDK respects window.__hf_ready__ and waits before capturing.
2. The Vercel filesystem
Vercel Functions have a read-only filesystem except for /tmp (512MB). If you cache rendered MP4s, write to /tmp. Better: stream the MP4 directly to the response and skip the cache. The route above does the latter.
3. Cold starts
A Vercel cold start that includes spinning up headless Chromium is ~3s. For end-user-facing renders, you want this on a warm instance — set preferredRegion and consider Vercel's Function Concurrency. For batch renders, cold starts amortize fine.
4. CORS
If the renderer fetches external assets (CSS, images, fonts from a CDN), CORS applies. Either:
- Inline all assets into the HTML (base64 images, embedded fonts via
data:URIs). - Set
Access-Control-Allow-Originheaders on your CDN.
Inlining is more reliable; CORS is more flexible. Pick based on which assets change.
Streaming versus buffered
The renderHtmlToMp4 call above returns the full MP4 as a buffer. For larger renders (over 10s, or 4K), you want to stream chunks back as the encoder produces them:
return new NextResponse(streamHtmlToMp4(html, opts), {
headers: { 'content-type': 'video/mp4' },
});streamHtmlToMp4 returns a ReadableStream. The browser starts playing the MP4 before the full encode completes — which, with +faststart flagged in the encoder, is the right UX.
Wiring into a UI
Once the route is live, the client side is trivial:
const res = await fetch('/api/render', {
method: 'POST',
body: JSON.stringify({ name, metric, delta }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
videoRef.current.src = url;The same pattern works for any Next.js integration or Vercel deployment. For higher-volume work, look at batch CSV-driven rendering — the route stays the same, you just call it N times.
What this unlocks
The point of a render-route is that it turns video into a function call. Once POST /api/render exists, every other surface in your app — emails, dashboards, social cards — can fetch a personalized MP4 with no special pipeline. The HTML template is the contract; everything else is plumbing.
See also: the developers overview for the full SDK surface, and the deterministic rendering manifesto for the principles underneath the API.
Cite this postBibTeX · APA · Markdown
@misc{tanaka2026render,
author = {Kira Tanaka},
title = {Render an MP4 from a Next.js API route (real example)},
year = {2026},
url = {https://hyperframes.video/blog/render-video-from-nextjs-route},
note = {HyperFrames blog}
}Kira Tanaka. (2026, May 8). Render an MP4 from a Next.js API route (real example). HyperFrames. https://hyperframes.video/blog/render-video-from-nextjs-route
[Render an MP4 from a Next.js API route (real example)](https://hyperframes.video/blog/render-video-from-nextjs-route) — Kira Tanaka, 2026
Kira works on the render core: headless Chromium scheduling, frame capture, and the encoder pipeline. She cares about reproducible builds and small numbers next to the word "variance."
Animated recipe card videos for social
Build a recipe card video for Instagram, TikTok, and Pinterest — ingredients check off line-by-line, a step counter ticks, and a circular timer fills. Rendered deterministically to MP4.
Animated timeline infographic generator
Generate a timeline infographic video from a JSON of milestones — a vertical spine draws downward, dots land on dates, labels slide in from alternate sides. Deterministic MP4.
Render an animated Gantt chart to MP4
An animated Gantt chart video built from a JSON of tasks. Horizontal bars that grow across a date axis with a moving today cursor — deterministic MP4 out.
Building with HyperFrames? Come hang out.
We're on GitHub, in Discord, and the playground is one click away. Bring weird ideas — we collect them.