Next.js¶
SSR React 19 genType
App Router を採用した Next.js 16 アプリケーションで、ReScript コンポーネントを genType 経由で公開し、POST /api/greet Route Handler を完全に ReScript で実装している。テンプレートは RSC の定番レイヤリングを 示している: サーバ側でデータを取得する非同期の Server Component、対話性を担う Client Component、そしてサーバ側エンドポイント用の Route Handler — それぞれが genType バインディングを介して ReScript に 裏打ちされている。
サーバサイドレンダリング、サーバ側のデータ取得、SEO、または App Router のプリミティブ(layouts、loading フォールバック、route handler)が必要な場合に本テンプレートを選択する。クライアントのみの React SPA で十分なら、Vite+ + React テンプレートの方が軽量である。
生成内容¶
my-project/
├── rescript.json # JSX automatic + genType + @rescript/react in bs-deps
├── package.json # private, "concurrently" wires rescript -w + next dev
├── next.config.mjs # default config — extend as needed
├── tsconfig.json # Next.js defaults + path mapping
├── rescript-modules.d.ts # ambient declare module "*.res.mjs"
├── src/
│ ├── App.res # @genType server-rendered component (takes serverGeneratedAt prop)
│ ├── GreetForm.res # @genType client component (state + fetch)
│ ├── Fetch.res # tiny POST helper shared by clients
│ ├── NextServer.res # bindings for next/server (NextRequest, NextResponse.json)
│ ├── __tests__/
│ │ └── App.test.mjs # smoke test that App.res.mjs exposes a component
│ └── app/
│ ├── page.tsx # async Server Component — calls App.gen + GreetForm
│ ├── loading.tsx # Suspense fallback
│ ├── client/
│ │ └── GreetForm.tsx # "use client" wrapper around GreetForm.gen
│ └── api/
│ └── greet/
│ ├── route.ts # one-line shim: re-exports post as POST
│ ├── GreetRoute.res # the actual handler body
│ └── Validation.res # zod or sury — selected in the wizard
├── README.md # script docs + RSC + Route Handlers + Project Layout
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + ReScript build + .next/ + out/ + .env*.local
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
pages/ ディレクトリは 存在しない。本テンプレートは App Router 専用である。
ウィザードオプション¶
オプション |
効果 |
|---|---|
Project name |
npm の |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
Next.js フレームワーク |
|
|
React 19 ランタイム |
|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル後の |
|
|
React バインディング(フック・コンポーネント・イベント) |
|
|
API ボディのバリデーション(ウィザードで選択) |
|
|
|
|
|
Next.js が要求し、genType が生成する |
|
|
React の型定義 |
|
|
|
|
|
スモークテストランナー |
|
|
|
|
主要なファイル¶
rescript.json¶
本テンプレート固有の 3 つのポイント:
bs-dependenciesに@rescript/reactが含まれているため JSX が型チェックされるjsx.mode = "automatic"により、JSX はreact/jsx-runtimeに展開されるgentypeconfigが有効 —@genTypeアノテーションが付与されたものはすべて、.res.mjs出力の隣に 対応する.gen.tsx(コンポーネント用)または.gen.d.tsを生成する
App Router は .tsx ファイルから ReScript コンポーネントを利用するため、genType はデフォルトで有効に なっている。これがないと、TypeScript は型なしのランタイムモジュールしか見られない。
src/app/page.tsx(Server Component)¶
App Router のすべてのルートファイルのデフォルトエクスポートは React コンポーネントである。async を付けることで、useEffect なし、クライアントウォーターフォールなし、API ラウンドトリップなしのサーバ側データ取得にオプトインできる:
import App from "../App.gen";
import GreetForm from "./client/GreetForm";
async function loadServerTimestamp(): Promise<string> {
// Replace with your server-only data source (process.env, Drizzle, Prisma, fetch).
return new Date().toUTCString();
}
export default async function Page() {
const serverGeneratedAt = await loadServerTimestamp();
return (
<main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
<App serverGeneratedAt={serverGeneratedAt} />
<GreetForm />
</main>
);
}
App は App.gen からインポートされる — これは genType が生成した .tsx ファイルであり、元の App.res ではない。これにより、TypeScript は ReScript コンポーネントの props に対する 本物の型を参照できる。
src/App.res(ReScript のサーバレンダリング可能なコンポーネント)¶
serverGeneratedAt という string の prop を受け取って描画する純粋なコンポーネントである。状態もクライアント専用 API も使わない:
@genType @react.component
let make = (~serverGeneratedAt: string) => {
<section>
<h1> {React.string("Welcome to <projectName>")} </h1>
<p>{React.string("This block is a Server Component. The form below is a Client Component.")}</p>
<p style={{color: "#666", fontSize: "0.875rem"}}>
{React.string("Rendered on the server at " ++ serverGeneratedAt)}
</p>
</section>
}
@genType @react.component の組み合わせは「この React コンポーネントを TypeScript の 呼び出し側に公開する」ための定番アノテーションである。@genType を外すと、コンポーネントは ReScript 専用になる。
src/app/client/GreetForm.tsx + src/GreetForm.res¶
Client Component は 2 ファイルのサンドイッチ構成である:
// app/client/GreetForm.tsx
"use client";
import GreetFormRescript from "../../GreetForm.gen";
export default function GreetForm() {
return <GreetFormRescript />;
}
"use client" ディレクティブは 境界 である: このファイルから(推移的に)インポートされるすべてが ブラウザバンドルに属することを Next.js に伝える。実際の ReScript コンポーネント(src/GreetForm.res)は React.useState を使い、イベントハンドラを取り付け、Fetch.post を呼び出す — これらはすべてクライアント上で 動作する必要がある。
// src/GreetForm.res
@genType @react.component
let make = () => {
let (name, setName) = React.useState(() => "")
let (greeting, setGreeting) = React.useState(() => None)
let handleSubmit = async event => {
ReactEvent.Form.preventDefault(event)
let response = await Fetch.post("/api/greet", JSON.stringifyAny({"name": name})->Option.getOr("{}"))
let body = await response->Fetch.json
setGreeting(_ => Some(body["message"]))
}
...
}
目安: 状態を持つ、あるいはイベント駆動の ReScript コンポーネントは "use client" 境界の内側に置く。純粋なコンポーネントや非同期データ取得を行うコンポーネントはサーバ側に残す。
src/app/api/greet/route.ts + GreetRoute.res(Route Handler)¶
Next.js はファイル名を route.ts / route.js / route.mjs にすることを要求する。一方、ReScript はモジュール名が 大文字 で始まることを要求する。テンプレートはこの競合を 1 行の 再エクスポートシムで解決する:
// route.ts
export { post as POST } from "./GreetRoute.res.mjs";
ハンドラの本体は GreetRoute.res に存在する:
let post = async (req: NextServer.nextRequest): NextServer.nextResponse => {
let raw = try await NextServer.reqJson(req) catch {
| _ => JSON.Object(Dict.make())
}
switch Validation.parseGreetInput(raw) {
| Ok(input) =>
NextServer.jsonResponse(
JSON.Object(Dict.fromArray([("message", JSON.String("Hello, " ++ input.name ++ "!"))])),
)
| Error(msg) =>
NextServer.jsonResponseWithInit(
JSON.Object(Dict.fromArray([("error", JSON.String(msg))])),
{"status": 400},
)
}
}
validate してから応答する順序が重要である: 型なしの HTTP ボディこそ Validation の真価が発揮される面である。不正な入力には { "error": "..." } とともに 400 を返し、妥当な入力には挨拶とともに 200 を返す。
src/NextServer.res¶
next/server への最小限のバインディング。POST ハンドラに必要な分だけを定義している:
type nextRequest
type nextResponse
@send external reqJson: nextRequest => promise<JSON.t> = "json"
@module("next/server") @scope("NextResponse")
external jsonResponse: JSON.t => nextResponse = "json"
@module("next/server") @scope("NextResponse")
external jsonResponseWithInit: (JSON.t, {..}) => nextResponse = "json"
Route Handler を追加していく際はこのファイルを拡張すること — バインディングを集中管理することで、@module("next/server") の external がプロジェクト全体に散らばるのを防げる。
src/app/api/greet/Validation.res¶
parseGreetInput: JSON.t => result<greetInput, string> — パース済みの HTTP ボディに対して 動作する。シグネチャは zod 版と sury 版で同一なので、GreetRoute.res はどちらが読み込まれているかで分岐しない。
JSON を受ける新しいエンドポイントを追加する場合は、姉妹となる Validation.res を同梱し、実処理を行う前に 実行すること。result を返すことで、HTTP 境界における契約が誠実に保たれる。
rescript-modules.d.ts¶
declare module "*.res.mjs";
ランタイムエントリポイントについて TypeScript が文句を言わないようにするための ambient 宣言である (さもなくば route.ts による ./GreetRoute.res.mjs の再エクスポートは型なしになってしまう)。genType が生成する .gen.tsx / .gen.d.ts ファイルは既にコンポーネント props 用の本物の型を持っている — この宣言はランタイムのみのモジュールをカバーする。
npm スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
build は意図的に ReScript のコンパイルを next build の 前 に置いている。Next.js は自身の バンドルパスの中で .res.mjs と .gen.tsx の出力を読むため、それらが先にディスク上に存在する必要がある。
2日目以降のレシピ¶
React コンポーネントの作成 — 新しい ReScript コンポーネントを追加し、genType 経由で公開する
TypeScript からの変換 — 既存の
.tsxページを.resコンポーネントに移植するImport の最適化 — アプリが成長しても
App.resとGreetForm.resを整然と保つデッドコードの検出 —
reanalyzeを実行して未使用のコンポーネントや バインディングを検出する
プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照すること。
補足¶
App Router 専用である。
pages/ディレクトリもgetServerSidePropsも存在しない。pages-router のプリミティブが必要であれば、本テンプレートは出発点として誤りである。ReScript ファイルは
.res.mjsにコンパイルされ、その後 TSX からインポートされる。 連鎖を常に意識すること:App.res→App.res.mjs(ランタイム)→App.gen.tsx(genType による型付きラッパー)→app/page.tsx(利用側)。編集対象はApp.res、利用側は*.gen.tsxから読む。Route Handler のファイル名衝突。
route.tsは Next.js の要求、GreetRoute.resは ReScript の要求である。新しいエンドポイントごとにシムパターン(1 行の再エクスポート)を維持し、route.tsにハンドラのロジックをインライン展開しないこと。バリデーションはサーバ上で動作する。 API ルートは信頼境界であり — HTTP 経由で入ってくるものは
Validation.parseGreetInputがOkを返すまで安全ではない。クライアント側のフォームが既に同じ フィールドをバリデートしていても、サーバ側のチェックを省いてはならない。クライアントは UX のためであって、セキュリティのためではない。concurrentlyのクォート。devはリテラルなエスケープ済みクォート付きでconcurrently "rescript -w" "next dev"として配線されている。3 つ目のプロセスを追加する場合は同じクォートスタイルに 従うこと。さもないとconcurrentlyは黙って引数をマージしてしまう。Server Components は
useStateを使えない。page.tsxからインポートされる、"use client"を持たない ReScript コンポーネント内でフックを呼び出そうとすると、Next.js は ビルド時に Server Component エラーで失敗する。状態を持つコンポーネントはapp/client/GreetForm.tsxのような"use client"ラッパーの内側に移動すること。@rescript/runtimeは直接依存でなければならない(デフォルトでそうなっている)。pnpm の strict レイアウトはユーザコードから推移的依存を隠す上、.res.mjsファイルはランタイムで@rescript/runtime/lib/es6/...からインポートする — 直接依存がなければ、dev サーバもnext buildもCannot find module '@rescript/runtime/...'を投げる。.gitignoreは ReScript のデフォルトに加えて.next/、out/、.env*.localを追加する ため、Next.js のビルドキャッシュ、static export の出力、ローカルの env ファイルがコミットされない。Vitest は API ルートを実行しない。 同梱のスモークテストは
App.res.mjsが関数を公開していることだけを チェックする。ハンドラにビジネスロジックを追加し始めた段階で、GreetRoute.res.mjsをインポートし偽のnextRequestを渡すテストを追加すること。