Remotion is a fantastic library for programmatic video rendering — you write React components and it renders them to MP4 using Chromium. But if you're embedding it inside a Next.js 15 App Router project, you'll quickly discover that Remotion and Next.js's bundler do not get along out of the box.
Here's every gotcha we hit, and exactly how to fix them.
The Problem
Remotionuses Node.js-only APIs (child_process, fs, native bindings for Chromium) and assumes a standalone Node environment. When Next.js tries to bundle your server-side code with webpack, it chokes on these imports:
Module not found: Can't resolve 'puppeteer-core'
Error: cannot find module '@remotion/renderer/...'
Or worse — it silently tree-shakes something critical and you get mysterious render failures in production.
Fix 1: Externalize Remotion in next.config.ts
Tell Next.js's webpack to leave Remotion packages to the Node.js runtime instead of trying to bundle them:
// next.config.ts
const nextConfig: NextConfig = {
serverExternalPackages: [
'@remotion/renderer',
'@remotion/bundler',
'@remotion/core',
'remotion',
],
};
export default nextConfig;
In Next.js 15,
experimental.serverComponentsExternalPackageswas promoted toserverExternalPackages(no moreexperimental.prefix).
Fix 2: Render via a Spawned Script, Not an API Route
Even with externalization, calling Remotion's renderMedia() directly from an App Router API route can cause issues — especially in edge runtimes or when the bundler partially processes the file.
The clean solution: keep your Remotion composition in an isolated folder (src/remotion/) and render it via a spawned child process.
// src/lib/render-video.ts
import { spawn } from 'child_process';
import path from 'path';
export async function renderCaptionedVideo(
videoUrl: string,
captions: Caption[],
outputPath: string
): Promise<string> {
return new Promise((resolve, reject) => {
const scriptPath = path.resolve(process.cwd(), 'src/scripts/render.mjs');
const args = [
scriptPath,
videoUrl,
JSON.stringify(captions),
outputPath,
];
const child = spawn('node', args, { stdio: 'inherit' });
child.on('exit', (code) => {
if (code === 0) resolve(outputPath);
else reject(new Error(`Remotion renderer exited with code ${code}`));
});
});
}
And the standalone render script:
// src/scripts/render.mjs
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
const [,, videoUrl, captionsJson, outputPath] = process.argv;
const captions = JSON.parse(captionsJson);
const bundled = await bundle({ entryPoint: './src/remotion/index.tsx' });
const composition = await selectComposition({
serveUrl: bundled,
id: 'CaptionedVideo',
inputProps: { videoUrl, captions },
});
await renderMedia({
composition,
serveUrl: bundled,
codec: 'h264',
outputLocation: outputPath,
});
console.log('Render complete:', outputPath);
This script runs completely outside Next.js's control — no bundling conflicts, no edge runtime surprises.
Fix 3: Exclude src/remotion from Next.js TypeScript
Remotioncompositions are React components that target the browser DOM. If your root tsconfig.json (which Next.js uses) includes src/remotion, TypeScript will complain about mismatched lib targets, browser-only APIs, etc.
Exclude it in your main tsconfig:
// tsconfig.json
{
"exclude": ["node_modules", "src/remotion", "src/scripts"]
}
And give the Remotion folder its own config:
// src/remotion/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"noEmit": true
},
"exclude": []
}
Run type-checks for Remotion separately: tsc -p src/remotion/tsconfig.json.
Fix 4: Use HTTPS URLs, Not Local File Paths
Remotionrenders via Chromium. Chromium cannot load a bare local path like /tmp/video.mp4 — it needs either a file:// URL or an HTTP(S) URL.
// ❌ Doesn't work in Chromium
const videoSrc = '/tmp/uploads/video.mp4';
// ✅ Works in local dev
const videoSrc = `file://${path.resolve('/tmp/uploads/video.mp4')}`;
// ✅✅ Works everywhere, including Docker + cloud workers
const videoSrc = await generatePresignedUrl(r2Key); // https://...
If you're running on a cloud worker (Railway, Fly, etc.) — go with presigned URLs. You get the HTTPS URL from your object storage (Cloudflare R2, S3, etc.) and pass it directly to the composition. No volume mounts needed.
Fix 5: Docker Setup for Remotion
Remotionneeds Chromium and its shared libraries. Here's a minimal Dockerfile setup that works on Railway:
FROM node:20-slim
# Chromium + system deps for Remotion rendering
RUN apt-get update && apt-get install -y \
chromium \
ffmpeg \
fonts-liberation \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Tell Remotion/Puppeteer where Chromium is
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV CHROME_EXECUTABLE_PATH=/usr/bin/chromium
Make sure to set CHROME_EXECUTABLE_PATH — Remotion uses this env var to locate the browser binary.
Summary
| Problem | Fix |
|---|---|
| Webpack bundling Remotion |
serverExternalPackages in next.config.ts
|
| API route render conflicts | Spawn a standalone Node.js script |
| TypeScript lib mismatch | Exclude src/remotion from root tsconfig |
| Chromium can't load local paths | Use file:// URIs or presigned HTTPS URLs |
| Missing Chromium in Docker | Install chromium + deps, set CHROME_EXECUTABLE_PATH
|
The key mental model: Remotion is not a Next.js library. It's a Node.js CLI tool that happens to be usable programmatically. Treat it that way — run it outside the Next.js process, feed it HTTPS URLs, and let Node handle it directly. Your webpack config will thank you.
Working on a Next.js video platform and hit a different Remotion quirk? Drop it in the comments.
Top comments (0)