Next.js App RouterでDate型をpropsに渡そうとしたら、こんなエラーが出たことありませんか?
Error: Only plain objects can be passed to Client Components from Server Components.
Date objects are not supported.
「Dateはシリアライズできるはずなのに、なぜ?」と思いますよね。この記事では、この問題の背景と、型安全性を保ちながら解決する方法を解説します。
なぜDateオブジェクトを渡せないのか
Server ComponentからClient Componentにpropsを渡すとき、ReactはJSON形式でデータをシリアライズします。
// Server Component
async function Page() {
const post = await getPost();
return <ArticleClient date={post.date} />; // ❌ エラー
}
// Client Component
'use client';
function ArticleClient({ date }: { date: Date }) {
return <time>{date.toLocaleDateString()}</time>;
}
問題はJSON.stringifyとJSON.parseの挙動です。
const date = new Date('2026-02-02');
const json = JSON.stringify({ date });
// → '{"date":"2026-02-02T00:00:00.000Z"}'
const parsed = JSON.parse(json);
// → { date: "2026-02-02T00:00:00.000Z" } // 文字列のまま!
Dateは文字列に変換されますが、JSON.parseでは自動的にDateに戻らない。Vercelチームはこのハイドレーション不一致を防ぐため、意図的にDateを禁止しています。
Vercelチームの見解
GitHub Issue #13209でのJoe Haddad(Vercel)の説明:
“This check is necessary as we do not serialize props on the server for performance reasons.”
Tim NeutkensもDiscussion #11498で:
“Date is serialized to a string and then on the client-side would cause a hydration error”
パフォーマンス優先の設計判断であり、変更する予定はなさそうです。
string vs Date:設計原則の観点から
ここで「じゃあ全部stringで渡せばいいじゃん」と思うかもしれません。でも、ちょっと待ってください。
Matias Kinunenのブログでは、こう主張しています:
“A date string is not a date; it’s a serialized format of a date.”
つまり、日付文字列は日付ではなく、シリアライズされた形式だと。
Viewコンポーネントの責務
コンポーネント設計の観点から考えてみましょう。
// Viewの責務は「どう表示するか」
function FormattedDate({ date }: { date: Date }) {
return <time>{format(date, 'yyyy/M/d')}</time>;
}
Viewコンポーネントは:
Dateを受け取る- 好きな形式にフォーマットする
これが自然な責務分離です。
// ❌ Viewがパースの責務を負っている
function FormattedDate({ date }: { date: string }) {
const parsed = new Date(date); // パースはViewの仕事?
return <time>{format(parsed, 'yyyy/M/d')}</time>;
}
stringを受け取ると:
- Viewがパースの責務を負う(責務違反)
- フォーマット形式の知識がViewに漏れる
- 不正な文字列のエラーハンドリングもViewの責務になる
「Viewがパースするのはおかしい」という直感は正しいと思います。でもNext.jsの技術的制約がある。このジレンマが厄介なんですよね。
現実的な解決策
設計原則と技術的制約の両立を考えると、いくつかのパターンがあります。
パターン1: Server Componentで完結させる
日付表示にインタラクティビティが不要なら、これが最もシンプルです。
// Server Component内で完結
async function Page() {
const post = await getPost();
return (
<article>
<h1>{post.title}</h1>
<FormattedDate date={post.date} /> {/* Server Component */}
<LikeButton postId={post.id} /> {/* Client Component - 日付不要 */}
</article>
);
}
// FormattedDateもServer Component
function FormattedDate({ date }: { date: Date }) {
return <time>{date.toLocaleDateString('ja-JP')}</time>;
}
Server Component → Server ComponentならDateをそのまま渡せます。
パターン2: 境界で変換してViewはDateを受け取る
どうしてもClient Componentで日付を使う場合、境界層で変換します。
// Server Component
async function Page() {
const post = await getPost();
return (
<InteractiveArticle
{...post}
date={post.date.toISOString()} // 境界で文字列化
/>
);
}
// Client Component(境界層)
'use client';
function InteractiveArticle({ date, ...rest }: { date: string }) {
const dateObj = new Date(date); // 即座にDateに変換
return (
<article>
<FormattedDate date={dateObj} /> {/* Dateを渡す */}
</article>
);
}
// Viewは常にDateを受け取る
function FormattedDate({ date }: { date: Date }) {
return <time>{date.toLocaleDateString('ja-JP')}</time>;
}
境界層がパースを担当し、View層は常にDateを受け取る。設計原則を守りつつ、技術的制約に対応できます。
パターン3: Branded Typeで型安全性を高める
plain stringだと、任意の文字列を渡せてしまいます。Branded Type(名目的型)を使えば、コンパイル時に変換忘れを防げます。
// 型定義
type ISODateString = string & { readonly __brand: unique symbol };
// 作成関数(ここで検証もできる)
function toISODateString(date: Date): ISODateString {
return date.toISOString() as ISODateString;
}
// 復元関数
function fromISODateString(iso: ISODateString): Date {
return new Date(iso);
}
使い方:
// Server Component
async function Page() {
const post = await getPost();
return (
<InteractiveArticle
date={toISODateString(post.date)} // 明示的に変換
/>
);
}
// ❌ plain stringを渡すと型エラー
<InteractiveArticle date="2026-02-02" /> // Type 'string' is not assignable to type 'ISODateString'
// ✅ toISODateStringを通すと型が通る
<InteractiveArticle date={toISODateString(new Date())} />
パターン4: superjsonで透過的に変換
superjsonを使えば、Date、Map、Setなどを透過的にシリアライズできます。
npm install superjson next-superjson-plugin
// next.config.js
module.exports = {
experimental: {
swcPlugins: [["next-superjson-plugin", {}]],
},
};
これでDateをそのまま渡せます…が、注意点があります。
superjsonは実データをjsonプロパティに、型情報をmetaプロパティに格納します。そのため、JSONサイズにオーバーヘッドが発生します。チャートデータなど大量の日付を扱う場合は要注意です。
また、SWC Pluginは実験的機能です。本番環境で使う場合はリスクを理解しておきましょう。
型定義のパターン
状態の組み合わせを型で制限する|React props設計開発実際のプロジェクトで使える型定義を整理します。
基本パターン: Branded Type
// types/date.ts
declare const brand: unique symbol;
export type ISODateString = string & { [brand]: 'ISODateString' };
export function toISODateString(date: Date): ISODateString {
return date.toISOString() as ISODateString;
}
export function fromISODateString(iso: ISODateString): Date {
const date = new Date(iso);
if (isNaN(date.getTime())) {
throw new Error(`Invalid ISO date string: ${iso}`);
}
return date;
}
DTOパターン: Server/Client境界用の型分離
// types/article.ts
export interface Article {
id: string;
title: string;
date: Date;
}
// Server → Client 境界用
export interface ArticleSerialized {
id: string;
title: string;
date: ISODateString;
}
// 変換関数
export function serializeArticle(article: Article): ArticleSerialized {
return {
...article,
date: toISODateString(article.date),
};
}
export function deserializeArticle(article: ArticleSerialized): Article {
return {
...article,
date: fromISODateString(article.date),
};
}
正直、DTO作るのは面倒です。でも型安全性と保守性を考えると、中〜大規模プロジェクトでは投資価値があると思います。
どのパターンを選ぶべきか
| パターン | メリット | デメリット | 適するケース |
|---|---|---|---|
| SC完結 | 最もシンプル | CCで日付使えない | 日付表示がstatic |
| 境界で変換 | 責務が明確 | 変換コードが必要 | 一般的なケース |
| Branded Type | 型安全 | 設定が必要 | 型を重視するチーム |
| superjson | 透過的 | バンドル肥大化 | 少数の日付のみ |
個人的には、**「Server Componentで完結」を基本にして、CCが必要な場合は「境界で変換 + Branded Type」**がバランス良いと思います。
コミュニティの声
この問題について、ネット上では様々な意見があります。
設計原則派
“日付文字列は日付ではない。パース時にDateオブジェクトに変換するのが意味論的に正しい” - mtsknn.fi
現実派
“YYYY-MM-DD形式の文字列は比較・ソートが直感的に動く。JSONで往復するならstringで統一した方が事故が少ない” - Atomic Object
不満派
“Dateは実際にはJSONでシリアライズ可能なのに、なぜ制限するのか” - GitHub Issue #13209のコメント
コミュニティには様々な意見があります。「Dateが正しい」という設計原則は広く認められていますが、Next.js/RSCの制約から「stringで妥協」が現実解になっています。
まとめ
Next.js App RouterでDateをpropsに渡せない問題は、技術的制約と設計原則のトレードオフです。
- Vercelチームの判断: パフォーマンスとハイドレーション一致のため、意図的にDateを禁止
- 設計原則: Viewはパースの責務を負うべきではない、Dateを受け取るべき
- 現実解: Server Componentで完結させるか、境界で変換してViewはDateを受け取る
型安全性を重視するなら、Branded Typeの導入をお勧めします。完璧な解決策ではありませんが、少なくともコンパイル時に変換忘れを防げます。