Remix (React Router v7) 完全実践ガイド〜loader/action・Nested Routes・Form・Session・Streaming SSR【2026年版】〜

Remix 入門を、loader / action / Form を実際に手で書きながら腹落ちさせたい」「Remix v2 と React Router v7(framework mode)の関係がよく分からない」「Next.js App Router との違いを、思想レベルで自分の言葉で説明できるようになりたい」――この記事はそんな現役WebエンジニアのためのRemix(React Router v7 framework mode)完全実践ガイドです。

2025年、Remix v2 はついに React Router v7 に統合され、「Remix v3」は別物として再出発する一方で、いま実務で使われている Remix のフルスタック機能(loader / action / Form / Resource Route / Session / Cookie / Streaming SSR / Nested Routes)は、すべてReact Router v7 の framework mode として継続提供されることになりました。本記事では、npm create remix@latest(またはnpx create-react-router@latest)で作る最新スタックを前提に、Vite ベースの開発体験・file-based routing・loader / useLoaderData・action / useActionData・<Form> の Progressive Enhancement・useFetcher / useSubmit・defer + Suspense + Await・並列フェッチ・ネストルート + Outlet・dynamic / catch-all / layout / pathless route・ErrorBoundary・meta / links / headers エクスポート・createCookieSessionStorage によるセッション・remix-auth・redirect / json / typedjson・Zod + zod-form-data によるバリデーション・Prisma / Drizzle 連携・Resource Route・SPA Mode・Cloudflare / Vercel デプロイ・Next.js App Router との対比までを、40 本超の TypeScript コードブロック・表 4 つ・FAQ 6 問で徹底解説します。

本記事は「SSR フレームワークとしての Remix」観点に特化しているため、フロントエンドフレームワーク徹底比較(選定観点)、React Router v7 完全実践ガイド(createBrowserRouter によるクライアントサイド SPA 観点)、React Server Components 完全ガイド(RSC / Server Actions 観点)と相互補完的に読むと、「Reactでサーバーサイドをどう書くか」という大問題の選択肢が一気に整理されます。

  1. 1. Remix と React Router v7 framework mode の全体像
    1. 1.1 Remix v2 → React Router v7 framework mode への移行マップ
    2. 1.2 そもそも Remix(framework mode)とは何者か
    3. 1.3 Next.js App Router との立ち位置の違い
  2. 2. プロジェクトを作る〜インストールから初回起動まで
    1. 2.1 React Router v7 framework mode で新規プロジェクト
    2. 2.2 プロジェクト構造を読み解く
    3. 2.3 package.json のスクリプトと依存
    4. 2.4 vite.config.ts
    5. 2.5 tsconfig.json の要点
  3. 3. root.tsx とアプリ全体の構造
    1. 3.1 root.tsx の役割
    2. 3.2 root レベル ErrorBoundary
    3. 3.3 root loader でグローバルデータを流す
  4. 4. ファイルベースルーティングの全体像
    1. 4.1 routes ディレクトリの命名規則(flat routes)
    2. 4.2 _index ルート
    3. 4.3 動的セグメント $id
    4. 4.4 catch-all $.tsx(404 ハンドラ)
    5. 4.5 layout route(_layout.tsx)
    6. 4.6 pathless route(URL に出さないレイアウト)
    7. 4.7 ネストを「抜ける」記法(_ サフィックス)
  5. 5. loader と useLoaderData〜サーバーサイドデータ取得
    1. 5.1 loader の基本
    2. 5.2 typed loader の型推論
    3. 5.3 リクエスト・クエリパラメータ・Cookie
    4. 5.4 redirect / 4xx を loader から投げる
    5. 5.5 data() ヘルパーでヘッダーやステータスを付ける
  6. 6. action と Form〜変更系の Web 標準実装
    1. 6.1 action の基本
    2. 6.2 <Form> の Progressive Enhancement
    3. 6.3 useNavigation でローディング表示
    4. 6.4 Zod + zod-form-data で型安全バリデーション
    5. 6.5 useSubmit による命令的送信
    6. 6.6 useFetcher で「ナビゲーションを伴わない」操作
  7. 7. ネストルート・並列フェッチ・Streaming
    1. 7.1 Outlet によるネスト
    2. 7.2 並列フェッチ(自動)
    3. 7.3 defer + Suspense + Await で遅延ストリーミング
    4. 7.4 ErrorBoundary を Suspense と組み合わせる
    5. 7.5 子ルートの loader からの型継承
  8. 8. セッション・認証・Resource Route
    1. 8.1 createCookieSessionStorage
    2. 8.2 ログイン action
    3. 8.3 ログアウト action
    4. 8.4 認可ガードを共通化する
    5. 8.5 Resource Route(JSON API として使う)
  9. 9. SEO・パフォーマンス・モジュール API
    1. 9.1 meta export(SEO)
    2. 9.2 links export(CSS / preload)
    3. 9.3 headers export(Cache-Control)
    4. 9.4 typedjson / superjson 連携
    5. 9.5 Prisma 連携(server-only)
    6. 9.6 Drizzle 連携(server-only)
    7. 9.7 ルートごとの ErrorBoundary
  10. 10. デプロイと SPA Mode・Next.js との対比
    1. 10.1 Node.js 単独サーバーで動かす
    2. 10.2 Vercel にデプロイ
    3. 10.3 Cloudflare Workers にデプロイ
    4. 10.4 SPA Mode(サーバーなし・静的ホスティング)
    5. 10.5 静的プリレンダリング
    6. 10.6 Next.js App Router との実コード対比
    7. 10.7 どちらを選ぶか〜2026 年版判断軸
  11. FAQ
  12. まとめ〜「Web 標準で書く React」を取り戻す

1. Remix と React Router v7 framework mode の全体像

2024年末、Remix チームは「Remix v2 を React Router v7 に merge する」という大きな意思決定を発表しました。これは単なるリブランドではなく、「ルーティングライブラリ」と「フルスタックフレームワーク」を 1 つのパッケージで提供するという統合です。実務的には、loader / action / Form / Session といった Remix の主要 API は、react-router パッケージから引き続き使えます。

1.1 Remix v2 → React Router v7 framework mode への移行マップ

主要モジュールの import パスがどう変わったかを最初に押さえます。コード本体のロジックはほぼそのままで、import を書き換えるだけで動くケースが大半です。

// Remix v2 (旧)
import { json, redirect } from "@remix-run/node";
import { useLoaderData, Form, Link } from "@remix-run/react";

// React Router v7 framework mode (新)
import { redirect } from "react-router";
import { useLoaderData, Form, Link } from "react-router";
// json() は非推奨。素のオブジェクト返却 or `data()` ヘルパーが推奨

1.2 そもそも Remix(framework mode)とは何者か

Remix は「Web 標準に強く寄せた React の SSR フレームワーク」です。フォーム送信は <Form method="post">、リダイレクトは HTTP 302、エラーは throw new Response(...)。JS が無効でも(JS 読み込み前でも)アプリが動くProgressive Enhancement がベースに据えられています。

1.3 Next.js App Router との立ち位置の違い

観点Remix / RR v7 frameworkNext.js App Router
ルーティングfile-based(routes/)+ flat or nestedfile-based(app/)+ folder ベース
データ取得route の loader 関数Server Components / fetch
変更系action + <Form>Server Actions
ストリーミングdefer + <Await>RSC + Suspense
クライアントナビSPA 的(loader 再実行)RSC 部分更新
デフォルト姿勢Web 標準・Progressive EnhancementReact 第一・新機能採用に積極

2. プロジェクトを作る〜インストールから初回起動まで

2.1 React Router v7 framework mode で新規プロジェクト

2026 年現在、新規に始めるなら create-react-router CLI が公式の入り口です。Vite ベース・TypeScript 標準・Tailwind / shadcn は後乗せ、というスタイル。

# React Router v7 framework mode(2026 推奨)
npx create-react-router@latest my-app
cd my-app
npm install
npm run dev
# → http://localhost:5173

「Remix」という名前で覚えている人向けには、依然として create-remix も動きます。生成されるコードは framework mode と互換です。

# 旧 Remix CLI(中身は React Router v7)
npm create remix@latest my-remix-app
cd my-remix-app
npm install
npm run dev

2.2 プロジェクト構造を読み解く

初期生成されるディレクトリ構造は次のようになります。重要なのは app/ 配下と routes/ です。

my-app/
├── app/
│   ├── entry.client.tsx     # ブラウザ側エントリ
│   ├── entry.server.tsx     # サーバー側エントリ
│   ├── root.tsx             # 全ページ共通の HTML シェル
│   ├── routes/              # file-based ルーティング
│   │   ├── _index.tsx       # "/"
│   │   ├── about.tsx        # "/about"
│   │   ├── posts.$id.tsx    # "/posts/:id"
│   │   └── ...
│   ├── components/
│   └── lib/
├── public/
├── package.json
├── vite.config.ts
└── tsconfig.json

2.3 package.json のスクリプトと依存

{
  "scripts": {
    "dev": "react-router dev",
    "build": "react-router build",
    "start": "react-router-serve ./build/server/index.js",
    "typecheck": "react-router typegen && tsc"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router": "^7.1.0",
    "isbot": "^5.1.0"
  },
  "devDependencies": {
    "@react-router/dev": "^7.1.0",
    "@react-router/node": "^7.1.0",
    "@react-router/serve": "^7.1.0",
    "vite": "^6.0.0",
    "typescript": "^5.5.0"
  }
}

2.4 vite.config.ts

import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [
    reactRouter(),
    tsconfigPaths(),
  ],
});

2.5 tsconfig.json の要点

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "types": ["@react-router/node", "vite/client"],
    "paths": { "~/*": ["./app/*"] }
  },
  "include": ["app", "**/*.ts", "**/*.tsx", ".react-router/types/**/*"]
}

3. root.tsx とアプリ全体の構造

3.1 root.tsx の役割

app/root.tsx はアプリ全体の HTML 骨格を定義するファイルです。<html> / <head> / <body> を自分で書くのが Remix 流。これによりメタタグ・スクリプト・CSS・スクロール挙動まで完全に握れます。

// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  useRouteError,
} from "react-router";
import type { LinksFunction, MetaFunction } from "react-router";
import styles from "./tailwind.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: styles },
  { rel: "icon", href: "/favicon.ico" },
];

export const meta: MetaFunction = () => [
  { charSet: "utf-8" },
  { name: "viewport", content: "width=device-width, initial-scale=1" },
  { title: "My Remix App" },
];

export default function App() {
  return (
    <html lang="ja">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

3.2 root レベル ErrorBoundary

// app/root.tsx(続き)
export function ErrorBoundary() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return (
      <html>
        <head><title>{error.status} Error</title><Meta /><Links /></head>
        <body>
          <h1>{error.status} {error.statusText}</h1>
          <p>{error.data}</p>
          <Scripts />
        </body>
      </html>
    );
  }
  const message = error instanceof Error ? error.message : "Unknown error";
  return (
    <html>
      <head><title>Oops</title><Meta /><Links /></head>
      <body>
        <h1>Application Error</h1>
        <pre>{message}</pre>
        <Scripts />
      </body>
    </html>
  );
}

3.3 root loader でグローバルデータを流す

// app/root.tsx(続き)
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { getUserFromSession } from "~/lib/session.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const user = await getUserFromSession(request);
  return { user, env: { APP_NAME: process.env.APP_NAME ?? "MyApp" } };
}

export default function App() {
  const { user, env } = useLoaderData<typeof loader>();
  return (
    <html lang="ja">
      <head><Meta /><Links /></head>
      <body>
        <header>
          {env.APP_NAME} {user ? `(${user.name})` : "(guest)"}
        </header>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

4. ファイルベースルーティングの全体像

4.1 routes ディレクトリの命名規則(flat routes)

React Router v7 framework mode は flat routes をデフォルトに採用しています。フォルダではなくファイル名のドット記法でネスト構造を表現するのが特徴です。

app/routes/
├── _index.tsx                # "/"
├── about.tsx                 # "/about"
├── posts._index.tsx          # "/posts"
├── posts.$id.tsx             # "/posts/:id"
├── posts.$id.edit.tsx        # "/posts/:id/edit"
├── posts.$id_.preview.tsx    # "/posts/:id/preview"(後述: _ サフィックス)
├── dashboard._layout.tsx     # レイアウト
├── dashboard.users.tsx       # "/dashboard/users"(layout 配下)
├── _auth.login.tsx           # "/login"(pathless: _auth レイアウト配下)
├── _auth.signup.tsx          # "/signup"
└── $.tsx                     # catch-all(404 など)

4.2 _index ルート

// app/routes/_index.tsx
export default function Index() {
  return (
    <main>
      <h1>Welcome</h1>
      <p>React Router v7 framework mode で動いています。</p>
    </main>
  );
}

4.3 動的セグメント $id

// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";

export async function loader({ params }: LoaderFunctionArgs) {
  // params.id は string | undefined
  const id = params.id!;
  return { id };
}

export default function PostPage() {
  const { id } = useLoaderData<typeof loader>();
  return <h1>Post: {id}</h1>;
}

4.4 catch-all $.tsx(404 ハンドラ)

// app/routes/$.tsx
import type { LoaderFunctionArgs } from "react-router";

export async function loader({ params }: LoaderFunctionArgs) {
  throw new Response("Not Found", { status: 404 });
}

export default function CatchAll() {
  return null; // ErrorBoundary が表示される
}

export function ErrorBoundary() {
  return <div><h1>404</h1><p>ページが見つかりません</p></div>;
}

4.5 layout route(_layout.tsx)

// app/routes/dashboard._layout.tsx
import { Outlet, NavLink } from "react-router";

export default function DashboardLayout() {
  return (
    <div className="grid grid-cols-[200px_1fr]">
      <aside>
        <NavLink to="/dashboard/users">Users</NavLink>
        <NavLink to="/dashboard/posts">Posts</NavLink>
      </aside>
      <main><Outlet /></main>
    </div>
  );
}

4.6 pathless route(URL に出さないレイアウト)

// app/routes/_auth.tsx(URL なし、見た目だけ共通化)
import { Outlet } from "react-router";

export default function AuthLayout() {
  return (
    <div className="min-h-screen grid place-items-center bg-gray-50">
      <div className="w-96 bg-white p-6 rounded shadow">
        <Outlet />
      </div>
    </div>
  );
}
// app/routes/_auth.login.tsx  → URL は "/login"
export default function Login() {
  return <h1>ログイン</h1>;
}

4.7 ネストを「抜ける」記法(_ サフィックス)

# posts.$id.tsx は posts._layout.tsx の中で描画される
# 一方、_ サフィックスを付けると親レイアウトを継承しない
app/routes/posts.$id_.preview.tsx
# → URL は /posts/123/preview だが、posts レイアウトは適用されない

5. loader と useLoaderData〜サーバーサイドデータ取得

5.1 loader の基本

loader 関数はサーバーで実行されます。ブラウザバンドルには絶対に含まれないため、DB アクセスや秘密鍵を扱う処理を直接書けます。

// app/routes/posts._index.tsx
import type { LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import { db } from "~/lib/db.server";

export async function loader(_: LoaderFunctionArgs) {
  const posts = await db.post.findMany({
    orderBy: { createdAt: "desc" },
    take: 20,
  });
  return { posts };
}

export default function PostList() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}><a href={`/posts/${p.id}`}>{p.title}</a></li>
      ))}
    </ul>
  );
}

5.2 typed loader の型推論

useLoaderData<typeof loader>() と書くだけで、戻り値の型(Date は string にシリアライズされる点も含めて)が自動推論されます。

// 戻り値の型は { posts: SerializeFrom<Post>[] } 相当
const { posts } = useLoaderData<typeof loader>();
//      ^? createdAt は string になる(JSON 経由のため)

5.3 リクエスト・クエリパラメータ・Cookie

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q") ?? "";
  const cookie = request.headers.get("Cookie") ?? "";
  const page = Number(url.searchParams.get("page") ?? "1");
  const posts = await db.post.findMany({
    where: { title: { contains: q } },
    skip: (page - 1) * 20,
    take: 20,
  });
  return { q, page, posts };
}

5.4 redirect / 4xx を loader から投げる

import { redirect } from "react-router";
import type { LoaderFunctionArgs } from "react-router";

export async function loader({ request, params }: LoaderFunctionArgs) {
  const user = await getUserFromSession(request);
  if (!user) throw redirect("/login");

  const post = await db.post.findUnique({ where: { id: params.id } });
  if (!post) throw new Response("Not Found", { status: 404 });
  if (post.authorId !== user.id) throw new Response("Forbidden", { status: 403 });

  return { post };
}

5.5 data() ヘルパーでヘッダーやステータスを付ける

import { data } from "react-router";

export async function loader() {
  const posts = await db.post.findMany();
  return data(
    { posts },
    {
      status: 200,
      headers: { "Cache-Control": "public, max-age=60, s-maxage=300" },
    }
  );
}

6. action と Form〜変更系の Web 標準実装

6.1 action の基本

action は POST / PUT / DELETE / PATCH などの変更系リクエストでサーバー実行される関数です。FormData を直接受け取って処理するのが Remix 流。

// app/routes/posts.new.tsx
import type { ActionFunctionArgs } from "react-router";
import { Form, redirect, useActionData } from "react-router";
import { db } from "~/lib/db.server";

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const title = String(form.get("title") ?? "").trim();
  const body = String(form.get("body") ?? "").trim();

  const errors: Record<string, string> = {};
  if (!title) errors.title = "タイトル必須";
  if (body.length < 10) errors.body = "本文は10文字以上";
  if (Object.keys(errors).length) return { errors };

  const post = await db.post.create({ data: { title, body } });
  throw redirect(`/posts/${post.id}`);
}

export default function NewPost() {
  const actionData = useActionData<typeof action>();
  return (
    <Form method="post">
      <label>
        Title
        <input name="title" defaultValue="" />
        {actionData?.errors?.title && <p>{actionData.errors.title}</p>}
      </label>
      <label>
        Body
        <textarea name="body" />
        {actionData?.errors?.body && <p>{actionData.errors.body}</p>}
      </label>
      <button type="submit">Save</button>
    </Form>
  );
}

6.2 <Form> の Progressive Enhancement

<Form> は HTML の <form> をラップしています。JS が読み込まれていない瞬間でも(あるいは無効でも)、ブラウザの標準フォーム送信としてそのまま動きます。

// JS off でも動く。JS on のときだけ XHR で送られる
<Form method="post" action="/posts/new" encType="multipart/form-data">
  <input name="title" />
  <input type="file" name="cover" />
  <button>Submit</button>
</Form>

6.3 useNavigation でローディング表示

import { Form, useNavigation } from "react-router";

export default function NewPost() {
  const nav = useNavigation();
  const submitting = nav.state === "submitting";
  return (
    <Form method="post">
      <input name="title" />
      <button disabled={submitting}>
        {submitting ? "保存中..." : "保存"}
      </button>
    </Form>
  );
}

6.4 Zod + zod-form-data で型安全バリデーション

// app/lib/validators.ts
import { z } from "zod";
import { zfd } from "zod-form-data";

export const newPostSchema = zfd.formData({
  title: zfd.text(z.string().min(1, "必須").max(120)),
  body: zfd.text(z.string().min(10, "10文字以上")),
  tags: zfd.repeatable(z.array(z.string()).optional()),
});
export type NewPostInput = z.infer<typeof newPostSchema>;
// app/routes/posts.new.tsx
import { newPostSchema } from "~/lib/validators";

export async function action({ request }: ActionFunctionArgs) {
  const result = newPostSchema.safeParse(await request.formData());
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
  const post = await db.post.create({ data: result.data });
  throw redirect(`/posts/${post.id}`);
}

6.5 useSubmit による命令的送信

import { useSubmit } from "react-router";

export function AutoSaveForm() {
  const submit = useSubmit();
  return (
    <form
      onChange={(e) => submit(e.currentTarget, { replace: true, method: "post" })}
    >
      <input name="title" />
    </form>
  );
}

6.6 useFetcher で「ナビゲーションを伴わない」操作

import { useFetcher } from "react-router";

export function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher();
  const liked = fetcher.formData?.get("liked") === "true";
  return (
    <fetcher.Form method="post" action={`/api/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={String(!liked)} />
      <button>{liked ? "♥" : "♡"}</button>
    </fetcher.Form>
  );
}

7. ネストルート・並列フェッチ・Streaming

7.1 Outlet によるネスト

// app/routes/dashboard._layout.tsx
import { Outlet, useLoaderData } from "react-router";

export async function loader() {
  return { now: new Date().toISOString() };
}

export default function DashLayout() {
  const { now } = useLoaderData<typeof loader>();
  return (
    <>
      <header>Dashboard ({now})</header>
      <Outlet />
    </>
  );
}

7.2 並列フェッチ(自動)

Remix の最大の強みは、ネストされたルートの loaderすべて並列で実行される点です。親が終わるのを待たずに子が走るため、ウォーターフォール問題が起きません。

// dashboard._layout.tsx の loader と
// dashboard.users.tsx の loader は同時に並列実行される
// ユーザーが /dashboard/users にアクセスした瞬間、
// サーバーは 2 つの loader を Promise.all で走らせる

7.3 defer + Suspense + Await で遅延ストリーミング

// app/routes/dashboard._index.tsx
import { Await, useLoaderData } from "react-router";
import { Suspense } from "react";

export async function loader() {
  const userStats = await getFastStats();          // 即返す
  const heavyReportPromise = getHeavyReport();      // Promise を返す(await しない)
  return { userStats, heavyReportPromise };
}

export default function Dash() {
  const { userStats, heavyReportPromise } = useLoaderData<typeof loader>();
  return (
    <>
      <StatsCard data={userStats} />
      <Suspense fallback={<p>Loading report…</p>}>
        <Await resolve={heavyReportPromise}>
          {(report) => <Report data={report} />}
        </Await>
      </Suspense>
    </>
  );
}

7.4 ErrorBoundary を Suspense と組み合わせる

<Suspense fallback={<p>Loading…</p>}>
  <Await resolve={heavyReportPromise} errorElement={<p>Failed</p>}>
    {(report) => <Report data={report} />}
  </Await>
</Suspense>

7.5 子ルートの loader からの型継承

// app/routes/dashboard.users.tsx
import { useRouteLoaderData } from "react-router";
import type { loader as rootLoader } from "~/root";

export default function Users() {
  const root = useRouteLoaderData<typeof rootLoader>("root");
  return <p>Hello {root?.user?.name ?? "guest"}</p>;
}

8. セッション・認証・Resource Route

8.1 createCookieSessionStorage

// app/lib/session.server.ts
import { createCookieSessionStorage } from "react-router";

type SessionData = { userId: string };
type SessionFlash = { error: string };

export const sessionStorage = createCookieSessionStorage<SessionData, SessionFlash>({
  cookie: {
    name: "__session",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
    secrets: [process.env.SESSION_SECRET!],
  },
});

export const { getSession, commitSession, destroySession } = sessionStorage;

8.2 ログイン action

// app/routes/_auth.login.tsx
import type { ActionFunctionArgs } from "react-router";
import { Form, redirect, useActionData } from "react-router";
import bcrypt from "bcryptjs";
import { db } from "~/lib/db.server";
import { getSession, commitSession } from "~/lib/session.server";

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  const email = String(form.get("email"));
  const password = String(form.get("password"));

  const user = await db.user.findUnique({ where: { email } });
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return { error: "メールアドレスかパスワードが違います" };
  }

  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);
  throw redirect("/dashboard", {
    headers: { "Set-Cookie": await commitSession(session) },
  });
}

export default function Login() {
  const data = useActionData<typeof action>();
  return (
    <Form method="post">
      {data?.error && <p className="text-red-600">{data.error}</p>}
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button>ログイン</button>
    </Form>
  );
}

8.3 ログアウト action

// app/routes/logout.tsx
import type { ActionFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { getSession, destroySession } from "~/lib/session.server";

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  throw redirect("/", {
    headers: { "Set-Cookie": await destroySession(session) },
  });
}

8.4 認可ガードを共通化する

// app/lib/auth.server.ts
import { redirect } from "react-router";
import { getSession } from "~/lib/session.server";
import { db } from "~/lib/db.server";

export async function requireUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");
  if (!userId) throw redirect("/login");
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user) throw redirect("/login");
  return user;
}
// 利用側
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request);
  return { user };
}

8.5 Resource Route(JSON API として使う)

default export を持たない route は「Resource Route」になり、JSON API / RSS / sitemap.xml 等を返す純粋なエンドポイントとして機能します。

// app/routes/api.posts.tsx
import type { LoaderFunctionArgs } from "react-router";
import { db } from "~/lib/db.server";

export async function loader(_: LoaderFunctionArgs) {
  const posts = await db.post.findMany({ take: 50 });
  return Response.json(posts, {
    headers: { "Cache-Control": "public, max-age=30" },
  });
}
// app/routes/sitemap[.]xml.tsx  → /sitemap.xml
export async function loader() {
  const posts = await db.post.findMany({ select: { id: true, updatedAt: true } });
  const xml = `<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${posts.map(p => `<url><loc>https://example.com/posts/${p.id}</loc></url>`).join("n")}
</urlset>`;
  return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}

9. SEO・パフォーマンス・モジュール API

9.1 meta export(SEO)

import type { MetaFunction } from "react-router";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) return [{ title: "Post not found" }];
  return [
    { title: data.post.title },
    { name: "description", content: data.post.excerpt },
    { property: "og:title", content: data.post.title },
    { property: "og:image", content: data.post.ogImage },
    { tagName: "link", rel: "canonical", href: `https://example.com/posts/${data.post.id}` },
  ];
};

9.2 links export(CSS / preload)

import type { LinksFunction } from "react-router";
import editorStyles from "./editor.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: editorStyles },
  { rel: "preload", as: "image", href: "/hero.webp" },
  { rel: "preconnect", href: "https://cdn.example.com" },
];

9.3 headers export(Cache-Control)

import type { HeadersFunction } from "react-router";

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=600",
  "Content-Security-Policy": "default-src 'self'",
});

9.4 typedjson / superjson 連携

Date や Map など JSON では失われる型を保ったまま転送したい場合に。

import { typedjson, useTypedLoaderData } from "remix-typedjson";

export async function loader() {
  return typedjson({ now: new Date(), tags: new Set(["a", "b"]) });
}

export default function Page() {
  const { now, tags } = useTypedLoaderData<typeof loader>();
  // now は Date のまま、tags は Set のまま
  return <p>{now.toISOString()}</p>;
}

9.5 Prisma 連携(server-only)

// app/lib/db.server.ts
import { PrismaClient } from "@prisma/client";

declare global { var __db__: PrismaClient | undefined; }
export const db = globalThis.__db__ ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.__db__ = db;

9.6 Drizzle 連携(server-only)

// app/lib/db.server.ts(Drizzle 版)
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });

9.7 ルートごとの ErrorBoundary

// app/routes/posts.$id.tsx
import { isRouteErrorResponse, useRouteError } from "react-router";

export function ErrorBoundary() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    if (error.status === 404) return <p>記事が見つかりません</p>;
    if (error.status === 403) return <p>閲覧権限がありません</p>;
  }
  return <p>予期せぬエラーが発生しました</p>;
}

10. デプロイと SPA Mode・Next.js との対比

10.1 Node.js 単独サーバーで動かす

npm run build
NODE_ENV=production npx react-router-serve ./build/server/index.js
# デフォルト http://localhost:3000

10.2 Vercel にデプロイ

npm i -D @vercel/react-router
# vercel.json は不要(プリセットで自動構成)
vercel --prod

10.3 Cloudflare Workers にデプロイ

// vite.config.ts(Cloudflare)
import { defineConfig } from "vite";
import { reactRouter } from "@react-router/dev/vite";
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";

export default defineConfig({
  plugins: [cloudflareDevProxy(), reactRouter()],
});
# Wrangler でデプロイ
npm i -D wrangler @react-router/cloudflare
npx wrangler deploy

10.4 SPA Mode(サーバーなし・静的ホスティング)

// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
  ssr: false,            // ← SPA モード
  prerender: ["/"],
} satisfies Config;

ssr: false にすると loader / action はビルド時のみ実行され、生成物は純粋な静的 SPA になります。Netlify / GitHub Pages / S3 + CloudFront にそのまま置けます。

10.5 静的プリレンダリング

// react-router.config.ts
export default {
  ssr: true,
  async prerender() {
    const ids = await fetch("https://api.example.com/post-ids").then(r => r.json());
    return ["/", "/about", ...ids.map((id: string) => `/posts/${id}`)];
  },
} satisfies Config;

10.6 Next.js App Router との実コード対比

機能Remix / RR v7Next.js App Router
サーバー取得loader 関数Server Component の fetch
変更action + <Form>Server Actions("use server")
ストリーミングdefer + <Await>RSC + Suspense
キャッシュHTTP ヘッダー基準fetch / unstable_cache 等
Progressive Enhancement標準で JS off 動作明示設計が必要
RSCRR v7.x で段階導入予定App Router の根幹

10.7 どちらを選ぶか〜2026 年版判断軸

こちらが向くケースRemix / RR v7 frameworkNext.js App Router
「Web 標準・Form 中心」で書きたい
Cloudflare Workers / 低価格エッジ△(機能制限あり)
RSC をフルに使いたい△(発展中)
採用事例・ライブラリ数
SPA からのなだらかな移行◎(ssr: false あり)
静的サイト寄りの構成◎(prerender)

FAQ

Q1. Remix と React Router v7 は別物?
2025 年以降は実質同じものです。Remix v2 の機能は React Router v7 の framework mode に取り込まれ、import パスが @remix-run/* から react-router に統一されました。

Q2. loader は毎回サーバーで実行されるの?
初回 SSR ではサーバー、その後のクライアントナビゲーションでは「サーバーへの fetch」になります。loader 本体のコードはブラウザに含まれません。

Q3. action は POST 以外でも動く?
動きます。<Form method="put|patch|delete"> も受け取れます。request.method で分岐するのが定石です。

Q4. defer は何が嬉しい?
遅い処理を「先に HTML を返してから後から流す」運用ができます。LCP を犠牲にせず重いダッシュボードを出せます。

Q5. RSC は使える?
2026 年現在、React Router v7 系では段階的に対応中です。短期では「loader + defer + Suspense」の組み合わせで RSC 相当の体験はほぼ作れます。

Q6. Next.js から移れる?
App Router 製アプリの場合、データ取得は「Server Component の fetch → loader」、Server Actions は「action 関数 + Form」へ移すのが基本動線です。ファイルベースルーティングの命名規則の差は最初に整理しておくとスムーズです。

まとめ〜「Web 標準で書く React」を取り戻す

Remix(React Router v7 framework mode)の本質は、HTTP・HTML フォーム・Cookie・ステータスコードという Web の根っこを、もう一度フロントエンドの主役に据えたことにあります。loader / action / <Form> / defer / Session という最小の語彙で、SSR・データ取得・更新・認証・ストリーミングまでをひと続きに書ける――この体験は、Next.js App Router を含む他のフレームワークでも完全には代替できません。

まずは npx create-react-router@latest で 1 プロジェクト作り、本記事の loader → useLoaderData → <Form> → action → useActionData → defer → Session の流れを手で 1 周してみてください。書き終えるころには、SSR フレームワークというものの「重さ」が、驚くほど軽く感じられるはずです。

関連記事として、フロントエンドフレームワーク徹底比較でフレームワーク全体の地図を、React Router v7 完全実践ガイドでクライアントサイド SPA としての側面を、React Server Components 完全ガイドで RSC / Server Actions との接続を補完すると、Remix の現在地が立体的に見えてくるはずです。

コメント

タイトルとURLをコピーしました