Remotion × Next.js — WebアプリにプログラマブルビデオをReact統合する
Remotion × Next.js — WebアプリにプログラマブルビデオをReact統合する
Remotionを使い始めたほとんどの開発者は、CLIベースのワークフローから入ります。npx create-video@latestでプロジェクトを作り、コンポジションを書き、npx remotion renderでMP4を書き出す流れです。これはバッチ処理や事前制作コンテンツには十分強力です。
しかし「ユーザーの入力に応じてリアルタイムに変化するアニメーションをWebアプリ内で見せたい」「テンプレートギャラリーでサンプルを動かしてプレビューしたい」「ユーザーが編集したらその場で動画が更新されるエディタを作りたい」という要件が生まれたとき、CLIワークフローでは対応できません。
そこで登場するのが Remotion Player です。そしてNext.jsはそのPlayerを活かすのに最も適したフレームワークです。本記事では、インストールから本番運用パターンまで、Remotion Next.js 統合の全体像を解説します。
Next.jsにおけるRemotionの2つの使い方
実装に入る前に、Next.jsでRemotionを使う目的を整理しましょう。用途によって使うパッケージがまったく異なります。
アプローチ1:Remotion Player(ブラウザ内プレビュー)
@remotion/playerは、動画ファイルを生成せずにコンポジションをブラウザ内でそのままレンダリングするReactコンポーネントです。ユーザーがページを開いた瞬間からアニメーションが動き、スクラブもでき、propsをリアルタイムに書き換えることで内容を即座に更新できます。
このアプローチが適しているのは:
- ダウンロード前のインタラクティブなプレビュー
- テキストや色を変えながらアニメーションが更新されるテンプレートエディタ
- ホバーで再生されるテンプレートギャラリー
- 「動画をUIとして使う」ケース全般
PlayerはブラウザAPIのみで動作し、Node.js環境は不要です。
アプローチ2:Renderer + SSR API(サーバーサイドレンダリング)
@remotion/rendererが提供するrenderMediaやrenderStillはNode.js上で実際の動画ファイルを生成するAPIです。Next.jsではAPIルート(またはRoute Handler)から呼び出します。
このアプローチが適しているのは:
- ダウンロード可能な
.mp4ファイルのオンデマンド生成 - 「レンダリングしてメール送信」のワークフロー
- サムネイル画像のサーバーサイド生成
本番アプリの多くはこの2つを組み合わせます。Playerでブラウザプレビューを提供し、「書き出し」ボタンが押されたらAPIルート経由でレンダリングを実行するパターンです。
インストール
既存のNext.jsプロジェクトにRemotion Playerを追加します。バージョンはコアのremotionパッケージと完全に一致させる必要があります:
npm install @remotion/player remotion
モノレポ構成でRemotionコンポジションが別パッケージにある場合は、そちらのバージョンに合わせてください。バージョン不一致は実行時エラーの最大の原因です。
React 18以上が必要ですが、Next.js 14以降はデフォルトでReact 18を使うため通常は問題ありません。
最初のPlayerコンポーネント
まずコンポジションを定義します。これは標準的なRemotionコードです:
// compositions/TitleComp.tsx
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
type Props = {
title: string;
subtitle: string;
};
export const TitleComp: React.FC<Props> = ({ title, subtitle }) => {
const frame = useCurrentFrame();
const opacity = interpolate(frame, [0, 20], [0, 1], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #1e1b4b, #312e81)",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 16,
}}
>
<h1 style={{ color: "white", fontSize: 72, opacity, margin: 0 }}>
{title}
</h1>
<p style={{ color: "#a5b4fc", fontSize: 32, opacity, margin: 0 }}>
{subtitle}
</p>
</AbsoluteFill>
);
};
次にNext.jsのページでPlayerを使います。App RouterとPages Routerどちらでも動作しますが、書き方が異なります:
// app/preview/page.tsx(App Router)
"use client";
import { Player } from "@remotion/player";
import { TitleComp } from "../../compositions/TitleComp";
export default function PreviewPage() {
return (
<main style={{ padding: 32 }}>
<h1>プレビュー</h1>
<Player
component={TitleComp}
inputProps={{ title: "2026年 Q1レポート", subtitle: "全社戦略発表" }}
durationInFrames={90}
compositionWidth={1920}
compositionHeight={1080}
fps={30}
style={{ width: "100%" }}
controls
/>
</main>
);
}
ファイルの先頭にある"use client"ディレクティブが必須である理由は次のセクションで詳しく説明します。
SSRとハイドレーションの落とし穴
Remotion PlayerはブラウザAPI(requestAnimationFrame、AudioContext、OffscreenCanvasなど)を内部で使用します。これらはNode.js環境に存在しないため、Playerはサーバーサイドレンダリングできません。
Next.js App Router(v13+)ではデフォルトですべてのコンポーネントがServer Componentとして扱われます。対処方法は2つあります。
方法A:ファイル全体をClient Componentにする
"use client";
// このファイル内でPlayerを自由にインポートできる
ページ全体がインタラクティブな場合はこれが最もシンプルです。
方法B:dynamic importでSSRを無効化する
ページの一部だけPlayerを使いたい場合、残りをServer Componentとして保ちつつPlayerだけクライアントに分離できます:
// app/gallery/page.tsx(Server Component)
import dynamic from "next/dynamic";
const VideoPreview = dynamic(
() => import("../../components/VideoPreview"),
{
ssr: false,
loading: () => (
<div style={{ aspectRatio: "16/9", background: "#1e1b4b" }} />
),
}
);
export default function GalleryPage() {
return (
<section>
<h2>テンプレートギャラリー</h2>
<VideoPreview />
</section>
);
}
// components/VideoPreview.tsx
"use client";
import { Player } from "@remotion/player";
import { TitleComp } from "../compositions/TitleComp";
export default function VideoPreview() {
return (
<Player
component={TitleComp}
inputProps={{ title: "サンプル", subtitle: "プレビュー" }}
durationInFrames={90}
compositionWidth={1920}
compositionHeight={1080}
fps={30}
controls
/>
);
}
ssr: falseオプションにより、Next.jsはこのコンポーネントのサーバーレンダリングをスキップします。ページのシェルはサーバーでレンダリングされ、Playerはクライアント側のハイドレーション後に注入されます。ハイドレーションミスマッチエラーを防ぐ最も確実な方法です。
inputPropsでデータを動的に渡す
Remotion Player Next.js 組み込みの真価はinputPropsにあります。ReactコンポーネントのpropsとしてPlayerに渡したオブジェクトは、コンポジション内でそのまま受け取れます。これはユーザー入力やデータベースの値と完全にリアルタイムで同期できます。
"use client";
import { useState } from "react";
import { Player } from "@remotion/player";
import { MetricsCard } from "../../compositions/MetricsCard";
export default function MetricsEditor() {
const [label, setLabel] = useState("月次売上");
const [value, setValue] = useState("¥12,400,000");
const [change, setChange] = useState("+23%");
return (
<div style={{ display: "flex", gap: 32, alignItems: "flex-start" }}>
{/* 編集パネル */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<label>
指標名
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
style={{ display: "block", marginTop: 4 }}
/>
</label>
<label>
値
<input
value={value}
onChange={(e) => setValue(e.target.value)}
style={{ display: "block", marginTop: 4 }}
/>
</label>
<label>
変化率
<input
value={change}
onChange={(e) => setChange(e.target.value)}
style={{ display: "block", marginTop: 4 }}
/>
</label>
</div>
{/* リアルタイムプレビュー */}
<Player
component={MetricsCard}
inputProps={{ label, value, change }}
durationInFrames={120}
compositionWidth={1080}
compositionHeight={1080}
fps={30}
style={{ width: 360 }}
controls
autoPlay
loop
/>
</div>
);
}
テキストを入力するたびにPlayerが即座に更新されます。動画ファイルの書き出しは発生せず、レンダーキューも存在しません。ReactのStateがそのままアニメーションに反映されます。
サーバーからデータを取得してPlayerに渡すパターン:
// app/reports/[id]/page.tsx(Server Component)
import ReportPlayer from "./ReportPlayer";
async function fetchReport(id: string) {
const res = await fetch(`https://api.example.com/reports/${id}`, {
cache: "no-store",
});
return res.json();
}
export default async function ReportPage({
params,
}: {
params: { id: string };
}) {
const data = await fetchReport(params.id);
// シリアライズ可能なデータとしてClient Componentに渡す
return <ReportPlayer reportData={data} />;
}
APIルートからサーバーサイドレンダリングを実行する
ブラウザプレビューで確認したユーザーが「書き出す」ボタンを押したとき、実際のMP4ファイルを生成するにはサーバーサイドの処理が必要です。
ここで重要な制約があります:
@remotion/bundlerはNext.jsのAPIルート内で使用できません。@remotion/bundlerはWebpackを内包していますが、Next.jsもWebpackでビルドされているため、WebpackをWebpackでバンドルしようとして失敗します。
解決策はビルド前にコンポジションをバンドルし、そのパスをAPIルートから参照することです。
ステップ1:ビルドスクリプトでコンポジションをバンドルする
// scripts/bundle-compositions.ts
import { bundle } from "@remotion/bundler";
import path from "path";
import fs from "fs";
const bundlePath = await bundle({
entryPoint: path.resolve("./compositions/index.ts"),
webpackOverride: (config) => config,
});
// 次のAPIルートから参照できるようパスを保存
fs.writeFileSync(".bundle-path", bundlePath);
console.log("バンドル完了:", bundlePath);
package.jsonのビルドスクリプトに組み込むか、Next.jsサーバー起動前に実行します。
ステップ2:APIルートでレンダリングを実行する
// app/api/render/route.ts(App Router)
import { renderMedia, getCompositions } from "@remotion/renderer";
import { NextResponse } from "next/server";
import path from "path";
import os from "os";
import fs from "fs";
function getBundlePath() {
const pathFile = path.resolve(".bundle-path");
if (!fs.existsSync(pathFile)) {
throw new Error(
"バンドルが見つかりません。scripts/bundle-compositions.ts を先に実行してください。"
);
}
return fs.readFileSync(pathFile, "utf-8").trim();
}
export async function POST(request: Request) {
const { compositionId, inputProps } = await request.json();
const bundlePath = getBundlePath();
const compositions = await getCompositions(bundlePath);
const composition = compositions.find((c) => c.id === compositionId);
if (!composition) {
return NextResponse.json(
{ error: `コンポジション "${compositionId}" が見つかりません` },
{ status: 404 }
);
}
const outputPath = path.join(os.tmpdir(), `render-${Date.now()}.mp4`);
await renderMedia({
composition,
serveUrl: bundlePath,
codec: "h264",
outputLocation: outputPath,
inputProps,
onProgress: ({ progress }) => {
console.log(`レンダリング進捗: ${Math.round(progress * 100)}%`);
},
});
// 本番環境ではS3やCloudflare R2にアップロードして署名付きURLを返す
return NextResponse.json({ outputPath, status: "complete" });
}
本番スケールではrenderMediaを@remotion/lambdaのrenderMediaOnLambdaに置き換えます。APIルートはLambdaの起動と進捗ポーリングだけを担当する薄いディスパッチャになります。
テンプレートギャラリーでの活用パターン
Remotion Next.js 統合の代表的なユースケースがテンプレートギャラリーです。静止画のスクリーンショットではなく、テンプレートをそのまま動かして見せることができます。
"use client";
import { Player } from "@remotion/player";
type TemplateItem = {
id: string;
label: string;
component: React.FC<any>;
defaultProps: Record<string, unknown>;
durationInFrames: number;
};
export function TemplateCard({ template }: { template: TemplateItem }) {
return (
<article
style={{
borderRadius: 12,
overflow: "hidden",
background: "#0f172a",
cursor: "pointer",
}}
>
<Player
component={template.component}
inputProps={template.defaultProps}
durationInFrames={template.durationInFrames}
compositionWidth={1920}
compositionHeight={1080}
fps={30}
style={{ width: "100%", display: "block" }}
autoPlay
loop
muted
controls={false}
showVolumeControls={false}
/>
<div style={{ padding: "12px 16px" }}>
<span style={{ color: "white", fontSize: 14 }}>{template.label}</span>
</div>
</article>
);
}
mutedとcontrols={false}でサムネイル的な用途に最適化します。compositionWidthとcompositionHeightは実際の制作解像度を指定し、表示サイズはCSSで制御するのが正しいパターンです。
パフォーマンス最適化
PlayerはそれぞれrequestAnimationFrameループを持ち、メディアのデコードも行います。ギャラリーページに多数のPlayerを並べると、すぐにCPUとメモリを圧迫します。
画面外のPlayerはマウントしない
Intersection Observer APIを使い、Playerがビューポートに入ったときだけマウントします:
"use client";
import { useRef, useState, useEffect } from "react";
export function LazyPlayerWrapper({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setVisible(true);
},
{ rootMargin: "300px" }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} style={{ aspectRatio: "16/9", background: "#0f172a" }}>
{visible ? children : null}
</div>
);
}
コンポジションをコード分割する
各コンポジションをdynamic importにすることで、ユーザーが実際に表示したコンポジションだけをダウンロードさせられます:
import dynamic from "next/dynamic";
const SalesComp = dynamic(
() => import("../compositions/SalesComp").then((m) => m.SalesComp),
{ ssr: false }
);
Playerを画面外でポーズする
PlayerのRefAPIを使い、ビューポート外に出たらポーズします:
import { useRef, useEffect } from "react";
import { PlayerRef } from "@remotion/player";
const playerRef = useRef<PlayerRef>(null);
// Intersection Observer のコールバック内で:
// entry.isIntersecting が false になったとき
playerRef.current?.pause();
よくある質問
App RouterのServer ComponentでPlayerを使えますか?
使えません。PlayerはブラウザAPIに依存するため、Client Component専用です。"use client"ディレクティブを付けるか、dynamic(() => import(...), { ssr: false })でインポートしてください。
APIルートで@remotion/rendererは使えますか?
使えます。renderMedia、getCompositions、renderStillはAPIルート内で動作します。ただし@remotion/bundlerは使えません。ビルドステップで事前にバンドルを作成し、そのパスをAPIルートから参照する方式にしてください。
Playerの初期化中にローディング表示をしたいのですが?
dynamic()のloadingオプションに() => <プレースホルダー />を渡すか、コンテナdivにCSSスケルトンを当ててください。Playerコンポーネント自体にはローディングコールバックがないため、コンテナ側でカバーするのがシンプルです。
Playerで音声は再生できますか?
できます。RemotionのAudioコンポーネントとオーディオシーケンスはPlayer内でフル動作します。ただしブラウザはデフォルトで音声付き自動再生をブロックするため、ギャラリー用途ではmutedプロップを使い、ユーザー操作で再生を開始させてください。
compositionWidth/compositionHeightとstyleのwidthの違いは何ですか?
compositionWidth/compositionHeightはコンポジションの設計解像度です(例:1920×1080)。styleプロップはPlayerがページ上で占める表示サイズです(例:width: 640)。PlayerはCanvasをstyleのサイズに合わせてスケールするため、設計解像度を変えずに表示サイズだけ変更できます。
RenderCompテンプレートでNext.js統合を加速する
本記事で紹介したパターンはそのまま本番に持ち込めますが、コンポジションをゼロから書くのは時間がかかります。RenderCompのテンプレートライブラリを使えば、inputPropsのインターフェースが型定義済みのReactコンポーネントとして届くため、Playerに渡すだけで動き出します。
テンプレートギャラリー、リアルタイムエディタ、自動動画生成パイプラインなど、Next.jsでの動画活用シナリオに合うテンプレートを rendercomp.com で確認してみてください。
RenderComp — プロダクションチームのためのRemotionアニメーションテンプレート