Waku:Next.js を使わずに React Server Components を動かす
WakuはNext.jsを使わずにReact Server Componentsを扱える軽量Reactフレームワークです。サンプルアプリを作り、ルーティングや描画、デプロイを解説します。
Waku は Vite 上に構築された小規模な React フレームワークで、React 19 のサーバーコンポーネントとサーバーアクションを実行できます。覚えるべきことはほぼ 2 つです。src/pages 内のファイルベースルーティングと、各ページで静的レンダリングか動的レンダリングかを指定する getConfig エクスポートです。
これまでサーバーコンポーネントを Next.js App Router の中でしか扱ってこなかった場合、どこまでが React の機能でどこからが Next.js の機能なのかを見分けるのは難しいでしょう。Waku は React のモデルをそのまま残し、その周囲にあるフレームワーク部分の大半を取り除いています。本記事では、雛形の作成からデプロイまで小さなアプリを 1 つ構築し、サイドプロジェクトで試す価値があるかを判断できるようにします。1.0 系はリリース候補(RC)として公開されています。v1.0.0-rc.2 は 2026 年 9 月 28 日に公開されました。安定版の 1.0 がリリースされているかどうかは、Waku のリリースページで確認してください。
重要なポイント
- Waku は Vite 上に構築されたミニマルな React フレームワークです。
src/pagesによるファイルベースルーティングを備え、レイアウトとページごとにrender: 'static'またはrender: 'dynamic'を返すgetConfigをエクスポートします。 - Waku のレイアウト、ページ、スライスはデフォルトで静的にレンダリングされます。ページがリクエストごとにレンダリングされるのは、その
getConfigがrender: 'dynamic'を返す場合だけです。 src/pages/releases/[slug].tsxのような静的なセグメントルートでは、getConfigからstaticPaths配列を返す必要があります。- Waku はデフォルトで Node.js にデプロイされ、純粋な静的出力、Vercel、Netlify、Cloudflare Workers にも対応しています。Deno Deploy、Bun、AWS Lambda は実験的サポートです。
- Waku 1.0 は v1.0.0-rc.2(2026 年 9 月 28 日)時点でリリース候補の段階にあります。そのため、プレリリースならではの仕様変更を許容できる小規模プロジェクトに適しています。
Waku の React プロジェクトはどう作成するか?
Waku の React プロジェクトを作成するには、npm create waku@latest を実行します。以降は waku dev、waku build、waku start の 3 つの CLI コマンドを使います。
npm create waku@latest
# then, inside the project:
npx waku dev # local dev server
npx waku build # production build
npx waku start # serve the production build
Waku の getting-started ドキュメントでは、サポート対象の Node.js バージョンとして ^26.0.0、^24.0.0、^22.15.0 が挙げられています。内部では、サーバーコンポーネントのバンドルに Vite 公式の @vitejs/plugin-rsc が使われています。設定ファイルにプラグイン用の vite キーがあるのはこのためです。
データを取得する非同期ページ
Waku では、src/pages 内に置かれ、コンポーネントをデフォルトエクスポートしているファイルがページになります。このコンポーネントは非同期のサーバーコンポーネントにでき、データを直接 await できます。Next.js でサーバーコンポーネントによるデータ取得を経験していれば、コンポーネント本体は見慣れた書き方のはずです。違いは、名前付きエクスポートの getConfig があることです。
外部 API に依存しないよう、この例ではローカルの JSON ファイルを読み込みます。Waku のドキュメントによると、プロジェクトルートの private フォルダ内のファイルは、サーバーコンポーネントから安全に読み込めます。
// private/releases.json
[
{ "slug": "v2-0", "version": "2.0.0", "summary": "New plugin API." },
{ "slug": "v1-9", "version": "1.9.0", "summary": "Bug fixes." }
]
// src/pages/index.tsx
import { readFile } from 'node:fs/promises';
type Release = { slug: string; version: string; summary: string };
export default async function HomePage() {
const releases: Release[] = JSON.parse(
await readFile('./private/releases.json', 'utf8'),
);
return (
<>
<title>Release notes</title>
<h1>Release notes</h1>
<ul>
{releases.map((release) => (
<li key={release.slug}>
<a href={`/releases/${release.slug}`}>{release.version}</a>
</li>
))}
</ul>
</>
);
}
export const getConfig = async () => {
return { render: 'dynamic' } as const;
};
getConfig が render: 'dynamic' を返すため、このページはリクエストごとにサーバーでレンダリングされます。この行がなければ、ビルド時にプリレンダリングされます。<title> タグが機能するのは、Waku が title、meta、link タグをドキュメントの head に巻き上げる(hoist する)ためです。
‘use client’ でクライアントコンポーネントを追加する
ファイルの先頭に 'use client' を記述すると、サーバーコンポーネントがそのファイルをインポートした時点で、そこがサーバーとクライアントの境界になります。境界より下では、そのファイルが読み込むすべてのコンポーネントがハイドレーションされ、ブラウザでも実行されます。これは App Router で使っているものと同じディレクティブであり、ルールも同じです。
// src/components/like-button.tsx
'use client';
import { useState } from 'react';
export const LikeButton = () => {
const [likes, setLikes] = useState(0);
return <button onClick={() => setLikes((n) => n + 1)}>👍 {likes}</button>;
};
クライアントコンポーネントはサーバーコンポーネントをインポートできません。ただし、サーバー側の出力を children などの props として渡せば、クライアントコンポーネントに届けられます。
// src/components/collapsible.tsx
'use client';
import { useState, type ReactNode } from 'react';
export const Collapsible = ({ children }: { children: ReactNode }) => {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((o) => !o)}>{open ? 'Hide' : 'Details'}</button>
{open && children}
</div>
);
};
// src/pages/index.tsx (inside the map, with both components imported)
<li key={release.slug}>
<a href={`/releases/${release.slug}`}>{release.version}</a>
<LikeButton />
<Collapsible>
<p>{release.summary}</p>
</Collapsible>
</li>
summary の段落はサーバーでレンダリングされ、props として Collapsible に渡されます。クライアント側のファイルがこの段落をインポートすることはありません。React サーバーコンポーネントでよくある誤解の 1 つが、'use server' の役割です。Waku のドキュメントでは、このディレクティブはサーバーコンポーネントではなくサーバーアクションを示すものとされています。したがって、サーバーコンポーネントのファイル先頭に書くものではありません。なお、クライアントコンポーネントもハイドレーションの前にサーバーで HTML にレンダリングされます。このようなページをセッションリプレイで確認すると、いいねボタンが最初のクリックに反応するのか、ハイドレーションが完了するまで反応しないのかがわかります。
ルーティング:レイアウト、動的セグメント、レンダリングモード
Waku では、特に指定しない限り、レイアウト、ページ、スライスは静的にレンダリングされます。API ハンドラーはその逆で、デフォルトで動的です。レンダリングモードはファイル単位で設定するため、静的なレイアウトで動的なページを包むこともできます。
src/
pages/
_layout.tsx
index.tsx
releases/
[slug].tsx
components/
like-button.tsx
collapsible.tsx
private/
releases.json
_layout.tsx ファイルは、自身のルートと、その配下にネストされたすべてのルートに適用されます。また、そのコンポーネントは children prop を受け取る必要があります。
// src/pages/_layout.tsx
import type { ReactNode } from 'react';
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
<>
<header><a href="/">Release notes</a></header>
<main>{children}</main>
</>
);
}
export const getConfig = async () => {
return { render: 'static' } as const;
};
ブラケットで囲まれた名前のファイルはセグメントルートです。セグメントの値は prop として渡され、waku/router の PageProps で型付けできます。Waku のルーティングドキュメントでは、セグメントルートが静的な場合、プリレンダリングする値を列挙した staticPaths 配列を getConfig から返すことが必須とされています。getConfig は非同期にできるため、このリストをデータから生成できます。
// src/pages/releases/[slug].tsx
import { readFile } from 'node:fs/promises';
import type { PageProps } from 'waku/router';
type Release = { slug: string; version: string; summary: string };
const loadReleases = async (): Promise<Release[]> =>
JSON.parse(await readFile('./private/releases.json', 'utf8'));
export default async function ReleasePage({ slug }: PageProps<'/releases/[slug]'>) {
const release = (await loadReleases()).find((r) => r.slug === slug);
if (!release) return <p>Release not found.</p>;
return (
<>
<title>{`Release ${release.version}`}</title>
<h1>{release.version}</h1>
<p>{release.summary}</p>
</>
);
}
export const getConfig = async () => {
const releases = await loadReleases();
return { render: 'static', staticPaths: releases.map((r) => r.slug) } as const;
};
スライス、インターセプター、Hono ミドルウェアも必要に応じて利用できますが、今回の小さなアプリではいずれも使用していません。
Waku はどこにデプロイできるか?
Waku はデフォルトで Node.js にデプロイされます。純粋な静的出力、Vercel、Netlify、Cloudflare Workers にも対応しています。Waku のデプロイドキュメントでは、Deno Deploy、Bun、AWS Lambda は実験的サポートとされています。
- Node.js:
waku startで本番サーバーを起動します。スタンドアロンで動かす場合は、distフォルダを配置してnode dist/serve-node.jsを実行します。 - 純粋な SSG:
dist/publicを任意の静的ホスティングにアップロードします。 - Vercel:
vercel - Netlify:
NETLIFY=1 npm run buildの後、netlify deploy - Cloudflare Workers:
CLOUDFLARE=1 npm run buildの後、wrangler deploy - Deno Deploy、Bun、AWS Lambda(実験的):
src/waku.server.tsxでwaku/adapters/deno、waku/adapters/bun、waku/adapters/aws-lambdaのいずれかをインポートします。
純粋な静的出力では、リクエスト時にサーバーを必要とする機能、つまり動的レンダリング、サーバーアクション、API ルートがすべて使えなくなります。そのため、前述の動的なホームページを SSG としてデプロイするには、render: 'static' に変更する必要があります。
Next.js を選ぶべきなのはどんなときか?
安定版リリースが必要であれば、Next.js を選ぶほうが安全です。Waku 1.0 は v1.0.0-rc.2 の時点でまだリリース候補でした。rc.2 のリリースノートには「breaking: drop deprecated apis」とあり、バージョン間で変更が入ることを想定しておくべきです。また、Next.js にははるかに大きなエコシステムがあり、ホスティングとの連携やチュートリアルも豊富です。組み込み機能も多く、Waku であれば自分で組み合わせる必要がある部分も最初から揃っています。Waku のドキュメントでは、どちらを選ぶかはプロジェクトの規模ではなく、求めるアーキテクチャによって決まると説明されています。Waku はフレームワーク本体を薄く保ち、それ以外はエコシステムのライブラリに任せます。一方、重量級のフレームワークはそうした部分の多くを自ら担います。こうした判断をフレームワークに任せたいなら、Next.js を使い続けるのがよいでしょう。
まとめ
Waku を使えば、App Router でおなじみのサーバーコンポーネントのモデルを、はるかに薄いフレームワークで扱えます。非同期ページ、同じ 'use client' 境界、children によるコンポジションに加え、静的か動的かの選択をファイルごとに明示する getConfig エクスポートが利用できます。次のステップとして、npm create waku@latest を実行し、Next.js の小さなルートを 1 つ Waku で作り直してみてください。その際は、今後のリリース候補で知らないうちに挙動が変わらないよう、package.json で RC のバージョンを厳密に固定しておきましょう。
よくある質問
Waku で API ルートを追加するには?
src/pages/_api 配下にファイルを追加し、そのルートで処理する HTTP メソッド(GET、POST、PUT、PATCH、DELETE など)ごとに関数をエクスポートします。ルートはファイル名から決まります。各ハンドラーは標準の Request を受け取り、標準の Response を返します。API ハンドラーはデフォルトで動的にレンダリングされますが、getConfig で render に 'static' を返すとビルド時にプリレンダリングされます。これは RSS フィードのような出力に適しています。
Waku で環境変数はどう扱うのか?
サーバー側のコードでは、'waku' からインポートした getEnv 関数で環境変数を読み取ります。Node.js 環境では process.env も使えます。クライアントコンポーネントから読み取れるのは WAKU_PUBLIC_ プレフィックスを持つ変数のみで、import.meta.env.WAKU_PUBLIC_HELLO のように import.meta.env 経由でアクセスします。このプレフィックスが付いた値は本番用 JavaScript バンドルにプレーンテキストとして含まれるため、API キーなどの機密情報には絶対に WAKU_PUBLIC_ プレフィックスを付けないでください。
Waku の内部ナビゲーションには a タグと Link コンポーネントのどちらを使うべきか?
内部リンクには 'waku' からインポートした Link コンポーネントを使用してください。to prop にはルート文字列か、to、params、search、hash を持つ構造化オブジェクトを指定でき、ナビゲーションは Waku のルーターによってクライアント側で行われます。通常の a タグの場合は、ブラウザの通常のページ遷移になります。プログラムからナビゲーションしたい場合や、現在のパスやクエリを読み取りたい場合は、クライアントコンポーネント内で 'waku' の useRouter フックを呼び出します。
Waku のサーバーアクションはデフォルトで安全か?
いいえ。'use server' を付けた関数は、クライアントから呼び出せるエンドポイントになります。Waku のドキュメントでも、関数内に認証・認可のチェックを自分で書かない限り、これらのエンドポイントは何によっても保護されないと警告されています。すべてのサーバーアクションでユーザーの本人確認と権限チェックを行ってください。また、意図しないエンドポイントを作ってしまわないよう、このディレクティブは公開するつもりの関数にだけ付けてください。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k