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 の name、LICENSE 保有者、および App.res<h1> テキストとなる

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README のインストールコマンド、CI のキャッシュキーに影響する

Validation library

zodsury。同梱される src/app/api/greet/Validation.res のバリアントを選択する — バリデータは API ルート内でパース済みの JSON ボディに対して動作する

主要な依存

パッケージ

用途

バージョン

next

Next.js フレームワーク

TemplateVersions.NEXTJS

react / react-dom

React 19 ランタイム

TemplateVersions.REACT / TemplateVersions.REACT_DOM

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル後の .res.mjs がランタイムでインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

@rescript/react

React バインディング(フック・コンポーネント・イベント)

TemplateVersions.RESCRIPT_REACT

zod または sury

API ボディのバリデーション(ウィザードで選択)

TemplateVersions.ZOD / TemplateVersions.SURY

concurrently (dev)

rescript -wnext dev を 1 つのプロセスで並行実行

TemplateVersions.CONCURRENTLY

typescript (dev)

Next.js が要求し、genType が生成する .gen.tsx / .gen.d.ts にも必要

TemplateVersions.TYPESCRIPT

@types/react / @types/react-dom (dev)

React の型定義

TemplateVersions.REACT_TYPES / TemplateVersions.REACT_DOM_TYPES

@types/node (dev)

next.config.mjs と route handler が利用する Node の型定義

TemplateVersions.NODE_TYPES

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 用のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

主要なファイル

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>
  );
}

AppApp.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 スクリプト

スクリプト

説明

dev

concurrently "rescript -w" "next dev" — ReScript の watch と dev サーバを同時に動かす

build

rescript && next build — まず ReScript をコンパイルし、その後 Next.js をビルド

start

next start — 本番用の Next.js サーバを起動

test

vitest run — スモークスイートを実行

test:coverage

vitest run --coverage — 同上に v8 のカバレッジレポートを付与

res:build

rescript — ワンショットの ReScript コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除

res:dev

rescript -w — 保存時に再コンパイル(dev にも組み込まれている)

build は意図的に ReScript のコンパイルを next build に置いている。Next.js は自身の バンドルパスの中で .res.mjs.gen.tsx の出力を読むため、それらが先にディスク上に存在する必要がある。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照すること。

補足

  • App Router 専用である。 pages/ ディレクトリも getServerSideProps も存在しない。pages-router のプリミティブが必要であれば、本テンプレートは出発点として誤りである。

  • ReScript ファイルは .res.mjs にコンパイルされ、その後 TSX からインポートされる。 連鎖を常に意識すること: App.resApp.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.parseGreetInputOk を返すまで安全ではない。クライアント側のフォームが既に同じ フィールドをバリデートしていても、サーバ側のチェックを省いてはならない。クライアントは 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 buildCannot find module '@rescript/runtime/...' を投げる。

  • .gitignore は ReScript のデフォルトに加えて .next/out/.env*.local を追加する ため、Next.js のビルドキャッシュ、static export の出力、ローカルの env ファイルがコミットされない。

  • Vitest は API ルートを実行しない。 同梱のスモークテストは App.res.mjs が関数を公開していることだけを チェックする。ハンドラにビジネスロジックを追加し始めた段階で、GreetRoute.res.mjs をインポートし偽の nextRequest を渡すテストを追加すること。