この記事の要点
• Remixのloader/actionによるサーバーサイドデータ処理
• ネストルーティングとプログレッシブエンハンスメント
• エラーバウンダリとリソースルートの活用
このチュートリアルで学ぶこと
- ✓ Remixプロジェクトのセットアップ
- ✓ ネストされたルーティング
- ✓ loader/action によるデータ処理
- ✓ フォームとプログレッシブエンハンスメント
- ✓ エラーバウンダリとリソースルート
- ✓ デプロイまでの流れ
前提条件
- React / TypeScript の基礎知識
- Node.js 20 以上
- ターミナル操作に慣れていること
- HTTP / REST の基本
プロジェクトのセットアップ
# Remix + Vite テンプレートで新規作成
npx create-remix@latest my-remix-app
# プロンプトに従って TypeScript を選択
cd my-remix-app
npm install
npm run dev
ディレクトリ構造
my-remix-app/
├── app/
│ ├── root.tsx # ルートドキュメント
│ ├── entry.client.tsx # クライアントエントリ
│ ├── entry.server.tsx # サーバーエントリ
│ ├── routes/ # ファイルベースのルート
│ │ ├── _index.tsx # /
│ │ ├── about.tsx # /about
│ │ └── posts.$id.tsx # /posts/:id
│ └── styles/
├── public/
├── vite.config.ts
└── package.json
基本概念
ポイント: RemixはWebの標準API(Form, Request, Response)を中心に据えたフレームワークです。loaderでデータ取得、actionでデータ変更というシンプルな2つのプリミティブで構成されています。
Remixは「Webの基礎 (Form, Request, Response, Cookie)」を中心に据えたフルスタックフレームワークです。
サーバー側の loader でデータ取得し、action でフォーム送信を処理し、クライアントではReactコンポーネントとして描画します。
ネストルーティングにより、URLに対して複数のレイアウトを合成できます。
ブラウザ ──GET──▶ loader ──▶ component ──▶ HTML
ブラウザ ──POST─▶ action ──▶ redirect/loader再実行
Step 1: ルートドキュメントの作成
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import styles from "./styles/global.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];
export default function App() {
return (
<html lang="ja">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<header>
<nav>
<a href="/">ホーム</a>
<a href="/posts">記事一覧</a>
</nav>
</header>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
Step 2: インデックスページとloader
// app/routes/_index.tsx
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "Remixデモ" },
{ name: "description", content: "Remix学習用サンプル" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const greeting = url.searchParams.get("name") ?? "ゲスト";
return json({ greeting, now: new Date().toISOString() });
}
export default function Index() {
const { greeting, now } = useLoaderData<typeof loader>();
return (
<main>
<h1>こんにちは、{greeting}さん</h1>
<p>サーバー時刻: {now}</p>
<Link to="/posts">記事一覧を見る</Link>
</main>
);
}
Step 3: ネストルーティングと動的パラメータ
// app/routes/posts.tsx
import { Outlet, NavLink, useLoaderData } from "@remix-run/react";
import { json } from "@remix-run/node";
export async function loader() {
const posts = [
{ id: "1", title: "Remixとは" },
{ id: "2", title: "loaderとaction" },
{ id: "3", title: "エラーバウンダリ" },
];
return json({ posts });
}
export default function PostsLayout() {
const { posts } = useLoaderData<typeof loader>();
return (
<div style={{ display: "flex", gap: 16 }}>
<aside>
<ul>
{posts.map((p) => (
<li key={p.id}>
<NavLink to={p.id}>{p.title}</NavLink>
</li>
))}
</ul>
</aside>
<section>
<Outlet />
</section>
</div>
);
}
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const id = params.id!;
const post = {
id,
title: `記事 ${id}`,
body: "Remixはloader/actionでデータを扱います。",
};
return json({ post });
}
export default function PostDetail() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
);
}
実践メモ: RemixのFormコンポーネントはJavaScriptが無効でも動作するプログレッシブエンハンスメントを実現します。useNavigationでローディング状態も制御できます。
Step 4: Formとaction
// app/routes/contact.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
type ActionData = { errors?: { name?: string; message?: string } };
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = String(formData.get("name") ?? "");
const message = String(formData.get("message") ?? "");
const errors: ActionData["errors"] = {};
if (name.length < 2) errors.name = "名前は2文字以上";
if (message.length < 5) errors.message = "本文は5文字以上";
if (Object.keys(errors).length > 0) {
return json<ActionData>({ errors }, { status: 400 });
}
// DB保存などを行う
return redirect("/contact/thanks");
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const busy = navigation.state === "submitting";
return (
<Form method="post">
<div>
<label>名前<input name="name" /></label>
{actionData?.errors?.name && <p>{actionData.errors.name}</p>}
</div>
<div>
<label>メッセージ<textarea name="message" /></label>
{actionData?.errors?.message && <p>{actionData.errors.message}</p>}
</div>
<button type="submit" disabled={busy}>
{busy ? "送信中..." : "送信"}
</button>
</Form>
);
}
注意: エラーバウンダリをルートごとに用意しないと、子ルートのエラーが親ルート全体を壊してしまいます。必ず各ルートにErrorBoundaryを設定しましょう。
Step 5: エラーバウンダリ
// app/routes/posts.$id.tsx に追加
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h2>{error.status} {error.statusText}</h2>
<p>{error.data}</p>
</div>
);
}
return <div>予期せぬエラー: {(error as Error).message}</div>;
}
Step 6: リソースルート (JSON API)
// app/routes/api.ping.ts
import { json } from "@remix-run/node";
export async function loader() {
return json({ ok: true, ts: Date.now() });
}
ブラウザで /api/ping にアクセスするとJSONを返す純粋なエンドポイントになります。
完成コード: ミニToDoアプリ
// app/routes/todos.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
type Todo = { id: string; title: string; done: boolean };
const store: Todo[] = [];
export async function loader() {
return json({ todos: store });
}
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const intent = form.get("intent");
if (intent === "create") {
const title = String(form.get("title") ?? "").trim();
if (title) {
store.push({ id: crypto.randomUUID(), title, done: false });
}
}
if (intent === "toggle") {
const id = String(form.get("id"));
const t = store.find((x) => x.id === id);
if (t) t.done = !t.done;
}
if (intent === "delete") {
const id = String(form.get("id"));
const idx = store.findIndex((x) => x.id === id);
if (idx >= 0) store.splice(idx, 1);
}
return json({ ok: true });
}
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div>
<h1>ToDo</h1>
<Form method="post">
<input name="title" required />
<button name="intent" value="create">追加</button>
</Form>
<ul>
{todos.map((t) => (
<li key={t.id}>
<Form method="post" style={{ display: "inline" }}>
<input type="hidden" name="id" value={t.id} />
<button name="intent" value="toggle">
{t.done ? "[x]" : "[ ]"}
</button>
</Form>
<span style={{ textDecoration: t.done ? "line-through" : "none" }}>
{t.title}
</span>
<Form method="post" style={{ display: "inline" }}>
<input type="hidden" name="id" value={t.id} />
<button name="intent" value="delete">削除</button>
</Form>
</li>
))}
</ul>
</div>
);
}
よくあるエラーと対処
-
Hydration mismatch
- サーバーとクライアントで異なる値 (Date.now等) を直接描画しない
- loaderで固定値を返すか、useEffectで差し替える
-
“You cannot submit a form from a loader”
- loaderは読み取り専用、変更はactionで行う
-
CORS / fetch エラー
- サーバー側 (loader/action) から外部APIを呼ぶ
- ブラウザから直接呼ぶ必要がある場合のみ corsヘッダを設定
-
型が any になる
- useLoaderData
() のようにジェネリクスで型付け
- useLoaderData
-
本番ビルド時の環境変数未定義
- process.env はサーバーのみ。クライアントで使うものは loader 経由で渡す
ベストプラクティス
- loader は最小限のデータを返す (オーバーフェッチ回避)
- action からは redirect を返して PRG パターンを守る
- useNavigation で送信中UIを出し、UXを向上
- エラーバウンダリをルートごとに用意する
- リソースルートでAPIとUIを同居させる
- Cookie / セッションは createCookieSessionStorage を使う
次のステップ
- 認証の実装 (remix-auth)
- Prismaとの連携
- Cloudflare Workers / Vercel へのデプロイ
- Remix Vite プラグインの活用
- React Router v7 への移行検討
まとめ
Remixはloader/actionというシンプルな2つのプリミティブでフルスタックアプリを組み立てられます。 Webの標準APIに沿った設計のため学習内容が応用しやすく、プログレッシブエンハンスメントにより JSが無効でも動くアプリを作れるのが強みです。
FAQ
Q. Next.js と Remix の違いは? A. どちらもReactベースのフルスタックフレームワークですが、RemixはWebの標準APIに寄り添い、 loader/actionを中心に据えたシンプルなモデルが特徴です。Next.jsはRSCやApp Router、画像最適化などの 機能が豊富で、Vercelとの統合が強力です。
Q. Remix は SSG できる?
A. Remixは基本的にSSR指向ですが、CDNキャッシュやHTTPキャッシュヘッダを使って静的配信に近い挙動を実現できます。
Cache-Control ヘッダを loader の戻り Response に付けて調整します。
Q. React Router v7 との関係は? A. Remixは React Router v7 にマージされつつあり、Remix/React Routerはより一体的なフレームワークへ 進化しています。最新の公式ドキュメントを確認してください。
Q. 型安全なloader/actionの書き方は?
A. useLoaderData<typeof loader>() のようにジェネリクスで型を受け取り、
json() ヘルパーの戻り値型から自動推論させます。
チートシート
// Remix 2.x / React Router v7
// https://remix.run/docs/en/main
loader // GET用データ取得 (サーバ側)
action // POST/PUT/DELETE (サーバ側)
useLoaderData() // loaderの結果取得
useActionData() // actionの結果取得
useNavigation() // 遷移 / 送信状態
Form // プログレッシブに動くフォーム
Link / NavLink // クライアント遷移
redirect() // PRGパターン
ErrorBoundary // ルート単位のエラー表示