Remix完全ガイド — Web標準準拠・Loader/Action・Nested Routes・フルスタック開発


Remixは2021年にオープンソース化されたフルスタックWebフレームワークです。React Routerの作者陣が開発し、「ブラウザとHTTPの仕様に忠実であること」を哲学の中核に据えています。Next.jsが独自のAPIやData Fetching規約を積み重ねてきたのとは対照的に、RemixはWeb標準——fetch API・FormData・HTTPレスポンス——を最大限に活用します。本記事ではプロジェクト作成から本番デプロイまで、Remixのすべてを実践的なTypeScriptコードとともに解説します。


1. Remixとは — Next.jsとの比較・Web標準の哲学

Remixが解決する問題

現代のフルスタックフレームワークは多くの場合、独自の「魔法」を積み重ねることで開発体験を向上させてきました。しかしその結果、開発者はフレームワーク固有のAPIを学ぶことに多くの時間を費やし、ブラウザやHTTPの本来の仕組みから遠ざかっていきます。

Remixはこの問題を逆転させました。フォーム送信は<form>要素のネイティブ動作を尊重し、データフェッチはサーバーのResponseオブジェクトを返し、エラー処理はHTTPステータスコードで表現します。学んだ知識がWeb標準の理解に直結するため、Remixの熟練はWeb開発全体の熟練でもあります。

フレームワーク比較

観点RemixNext.jsSvelteKit
データフェッチLoader(サーバー専用)getServerSideProps / RSCload関数
フォーム処理Action(Web標準)Server Actionsform actions
ルーティングファイルベース(React Router)App Router / Pages Routerファイルベース
レンダリングSSR優先・クライアント遷移SSG/SSR/ISRSSG/SSR
ランタイムNode.js / Edge / CF WorkersNode.js / EdgeNode.js / Edge
バンドラVite(v2以降)Turbopack / WebpackVite
哲学Web標準最優先DX最優先・独自APIシンプリシティ

Web標準への回帰という設計思想

Remixのローダー(Loader)は通常のasync関数であり、戻り値はResponseオブジェクトです。

// Web標準の Response をそのまま返せる
export async function loader() {
  const data = await fetchData();
  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  });
}

// Remix 提供のヘルパーを使うとより簡潔
export async function loader() {
  const data = await fetchData();
  return json(data); // 内部的に Response を生成
}

この設計のメリットは、フレームワーク固有の知識ではなく、HTTPとWebの知識がそのまま応用できることです。json()ヘルパーの中身を知らなくても、HTTPのContent-TypeヘッダーやJSONシリアライズの概念を理解していれば挙動が予測できます。

Progressive Enhancement(段階的機能向上)

RemixはフォームとリンクをネイティブHTML要素の動作で機能させます。JavaScriptが無効な環境でも、フォーム送信やページ遷移が正しく動作します。JavaScriptが有効な環境では、Remixがインターセプトしてクライアントサイドのナビゲーションに昇格させます。この「JavaScriptなしでも動く → JavaScriptがあればより良くなる」という段階的な設計がProgressive Enhancementです。


2. プロジェクト作成(create-remix・Vite設定)

インストールと初期セットアップ

Node.js 20.0以上が必要です。次のコマンドでプロジェクトを作成します。

# npm
npx create-remix@latest my-remix-app

# pnpm(推奨)
pnpm create remix@latest my-remix-app

# bun
bunx create-remix@latest my-remix-app

対話式ウィザードでテンプレートとデプロイターゲットを選択できます。本記事ではTypeScript + Viteをベースにしたシンプルな構成から始めます。

? Where would you like to initialize your app? ./my-remix-app
? Initialize a new git repository? Yes
? Install dependencies with npm? Yes

プロジェクト構造

my-remix-app/
├── app/
│   ├── entry.client.tsx    # クライアントエントリーポイント
│   ├── entry.server.tsx    # サーバーエントリーポイント
│   ├── root.tsx            # ルートレイアウト
│   └── routes/             # ファイルベースルーティング
│       ├── _index.tsx      # / ルート
│       └── about.tsx       # /about ルート
├── public/                 # 静的アセット
├── remix.config.js         # Remix設定(旧方式)
├── vite.config.ts          # Vite設定(v2以降推奨)
├── tsconfig.json
└── package.json

Vite設定(v2以降の推奨構成)

Remix v2からViteがデフォルトのビルドツールになりました。vite.config.tsで設定します。

// vite.config.ts
import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

TypeScript設定

// tsconfig.json
{
  "include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "isolatedModules": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "target": "ES2022",
    "strict": true,
    "allowJs": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./app/*"]
    }
  }
}

パスエイリアス~/を設定しておくことで、../../components/Buttonのような相対パスを~/components/Buttonと書けるようになります。

開発サーバー起動

pnpm dev
# http://localhost:5173 でアクセス可能

3. ルーティング(ファイルベース・Nested Routes・レイアウト)

ファイルベースルーティングの基本

Remixはapp/routes/ディレクトリのファイル構造からルーティングを自動生成します。

app/routes/
├── _index.tsx          → /
├── about.tsx           → /about
├── blog.tsx            → /blog(レイアウトルート)
├── blog._index.tsx     → /blog
├── blog.$slug.tsx      → /blog/:slug
├── blog.new.tsx        → /blog/new
├── dashboard.tsx       → /dashboard(レイアウトルート)
├── dashboard._index.tsx → /dashboard
├── dashboard.settings.tsx → /dashboard/settings
└── _auth.login.tsx     → /login(_auth はパスセグメントなし)

ドット(.)でパスの階層を表現し、アンダースコア(_)プレフィックスでパスに含まれないグループ(レイアウトのみ)を表現します。

基本ルートの実装

// app/routes/_index.tsx
import type { MetaFunction } from '@remix-run/node';

export const meta: MetaFunction = () => {
  return [
    { title: 'My Remix App' },
    { name: 'description', content: 'Remixで構築されたWebアプリ' },
  ];
};

export default function Index() {
  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold">Remix へようこそ</h1>
      <p className="mt-4 text-lg text-gray-600">
        Web標準に忠実なフルスタックフレームワーク
      </p>
    </main>
  );
}

Nested Routes(ネストルート)とOutlet

Remixのネストルートはファイル構造で表現します。親ルートに<Outlet />を置くことで、子ルートのコンテンツが挿入されます。

// app/routes/dashboard.tsx(親レイアウト)
import { Outlet, NavLink } from '@remix-run/react';

export default function DashboardLayout() {
  return (
    <div className="flex min-h-screen">
      {/* サイドバーナビゲーション */}
      <aside className="w-64 bg-gray-900 text-white p-4">
        <nav className="space-y-2">
          <NavLink
            to="/dashboard"
            end
            className={({ isActive }) =>
              `block px-4 py-2 rounded ${
                isActive ? 'bg-blue-600' : 'hover:bg-gray-700'
              }`
            }
          >
            概要
          </NavLink>
          <NavLink
            to="/dashboard/settings"
            className={({ isActive }) =>
              `block px-4 py-2 rounded ${
                isActive ? 'bg-blue-600' : 'hover:bg-gray-700'
              }`
            }
          >
            設定
          </NavLink>
        </nav>
      </aside>

      {/* 子ルートのコンテンツがここに挿入される */}
      <main className="flex-1 p-8">
        <Outlet />
      </main>
    </div>
  );
}
// app/routes/dashboard._index.tsx(子ルート: /dashboard)
export default function DashboardIndex() {
  return (
    <div>
      <h1 className="text-2xl font-bold">ダッシュボード概要</h1>
      <p className="mt-2 text-gray-600">ここに統計情報が入ります</p>
    </div>
  );
}

動的セグメント($param)

URLパラメータは$プレフィックスで定義します。

// app/routes/blog.$slug.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export async function loader({ params }: LoaderFunctionArgs) {
  const { slug } = params;
  // slug は string | undefined — 型安全にアクセスできる
  const post = await getPostBySlug(slug!);

  if (!post) {
    throw new Response('記事が見つかりません', { status: 404 });
  }

  return json({ post });
}

export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

4. Loader(サーバーサイドデータフェッチ・useLoaderData)

Loaderの基本概念

Loaderはルートごとに定義されるサーバー専用のasync関数です。ページを表示するために必要なデータをサーバーサイドで取得し、コンポーネントに渡します。Next.jsのgetServerSidePropsに相当しますが、より直感的なAPIを提供します。

// app/routes/users.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';

// Loader: サーバーサイドで実行される
export async function loader({ request }: LoaderFunctionArgs) {
  // request は Web 標準の Request オブジェクト
  const url = new URL(request.url);
  const page = Number(url.searchParams.get('page') ?? '1');

  const users = await db.user.findMany({
    skip: (page - 1) * 10,
    take: 10,
    orderBy: { createdAt: 'desc' },
  });

  return json({ users, page });
}

// コンポーネント: useLoaderData で型安全にデータを取得
export default function Users() {
  const { users, page } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>ユーザー一覧</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link to={`/users/${user.id}`}>{user.name}</Link>
          </li>
        ))}
      </ul>
      <div className="mt-4 flex gap-2">
        {page > 1 && <Link to={`?page=${page - 1}`}>前のページ</Link>}
        <Link to={`?page=${page + 1}`}>次のページ</Link>
      </div>
    </div>
  );
}

型安全なLoader(TypeScript活用)

useLoaderData<typeof loader>()とジェネリクスを活用することで、ローダーの戻り値の型をコンポーネントに自動推論できます。

// app/routes/products.$id.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { prisma } from '~/lib/db.server';

export async function loader({ params }: LoaderFunctionArgs) {
  const product = await prisma.product.findUnique({
    where: { id: params.id },
    include: {
      category: true,
      images: true,
      reviews: {
        take: 5,
        orderBy: { createdAt: 'desc' },
      },
    },
  });

  if (!product) {
    throw new Response(null, {
      status: 404,
      statusText: '商品が見つかりません',
    });
  }

  return json({
    product,
    // シリアライズが必要なデータは明示的に変換
    createdAt: product.createdAt.toISOString(),
  });
}

export default function ProductDetail() {
  // product の型は Prisma の型から自動推論される
  const { product, createdAt } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>カテゴリ: {product.category.name}</p>
      <p>登録日: {new Date(createdAt).toLocaleDateString('ja-JP')}</p>
    </div>
  );
}

並列データフェッチ

LoaderはPromise.allを使って複数のデータを並列取得できます。Next.jsのRSCと異なり、明示的に並列化することでパフォーマンスを制御できます。

// app/routes/dashboard._index.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  // 独立したクエリを並列実行 — 直列より高速
  const [stats, recentOrders, topProducts] = await Promise.all([
    getSiteStats(),
    getRecentOrders({ limit: 5 }),
    getTopProducts({ limit: 3 }),
  ]);

  return json({ stats, recentOrders, topProducts });
}

5. Action(フォーム処理・useActionData・redirect)

Actionの基本概念

ActionはHTTPのPOST/PUT/DELETE/PATCHリクエストを処理するサーバー関数です。Remixでは<Form>コンポーネントのsubmitがActionを呼び出し、処理後にredirect()json()でレスポンスを返します。

// app/routes/contacts.new.tsx
import {
  json,
  redirect,
  type ActionFunctionArgs,
} from '@remix-run/node';
import {
  Form,
  useActionData,
  useNavigation,
} from '@remix-run/react';

// バリデーション関数
function validateContact(data: FormData) {
  const errors: Record<string, string> = {};
  const name = data.get('name');
  const email = data.get('email');

  if (!name || typeof name !== 'string' || name.trim() === '') {
    errors.name = '名前は必須です';
  }
  if (!email || typeof email !== 'string' || !email.includes('@')) {
    errors.email = '有効なメールアドレスを入力してください';
  }

  return errors;
}

// Action: POST リクエストを処理
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const errors = validateContact(formData);

  // バリデーションエラーがある場合は 422 で返す
  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 422 });
  }

  await db.contact.create({
    data: {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      message: formData.get('message') as string,
    },
  });

  // 成功時は一覧ページにリダイレクト
  return redirect('/contacts?created=true');
}

// コンポーネント
export default function NewContact() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <div className="max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-6">新規コンタクト</h1>
      <Form method="post" className="space-y-4">
        <div>
          <label htmlFor="name" className="block text-sm font-medium">
            名前
          </label>
          <input
            id="name"
            name="name"
            type="text"
            className="mt-1 block w-full rounded border px-3 py-2"
            aria-describedby={actionData?.errors?.name ? 'name-error' : undefined}
          />
          {actionData?.errors?.name && (
            <p id="name-error" className="mt-1 text-sm text-red-600">
              {actionData.errors.name}
            </p>
          )}
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium">
            メールアドレス
          </label>
          <input
            id="email"
            name="email"
            type="email"
            className="mt-1 block w-full rounded border px-3 py-2"
          />
          {actionData?.errors?.email && (
            <p className="mt-1 text-sm text-red-600">
              {actionData.errors.email}
            </p>
          )}
        </div>

        <div>
          <label htmlFor="message" className="block text-sm font-medium">
            メッセージ
          </label>
          <textarea
            id="message"
            name="message"
            rows={4}
            className="mt-1 block w-full rounded border px-3 py-2"
          />
        </div>

        <button
          type="submit"
          disabled={isSubmitting}
          className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {isSubmitting ? '送信中...' : '送信する'}
        </button>
      </Form>
    </div>
  );
}

intent パターン(複数アクション)

1つのルートに複数の操作がある場合、intentフィールドでアクションを分岐させます。

// app/routes/todos.$id.tsx
export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  switch (intent) {
    case 'toggle': {
      await db.todo.update({
        where: { id: params.id },
        data: { completed: formData.get('completed') === 'true' },
      });
      return json({ ok: true });
    }
    case 'delete': {
      await db.todo.delete({ where: { id: params.id } });
      return redirect('/todos');
    }
    case 'update': {
      await db.todo.update({
        where: { id: params.id },
        data: { title: formData.get('title') as string },
      });
      return json({ ok: true });
    }
    default:
      throw new Response('不明なアクション', { status: 400 });
  }
}

6. Error Boundary・CatchBoundary(エラー処理)

ErrorBoundaryによるエラー処理

RemixではルートごとにErrorBoundaryをエクスポートすることで、そのルートで発生したエラーをキャッチして適切なUIを表示できます。

// app/routes/blog.$slug.tsx
import { isRouteErrorResponse, useRouteError } from '@remix-run/react';

export function ErrorBoundary() {
  const error = useRouteError();

  // HTTP エラー(404、500 など)
  if (isRouteErrorResponse(error)) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <h1 className="text-6xl font-bold text-gray-900">
            {error.status}
          </h1>
          <p className="mt-2 text-xl text-gray-600">{error.statusText}</p>
          {error.status === 404 && (
            <p className="mt-4 text-gray-500">
              お探しの記事は見つかりませんでした
            </p>
          )}
          <a
            href="/blog"
            className="mt-6 inline-block text-blue-600 hover:underline"
          >
            ブログ一覧に戻る
          </a>
        </div>
      </div>
    );
  }

  // 予期しないエラー(プログラムのバグなど)
  if (error instanceof Error) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="max-w-lg text-center">
          <h1 className="text-2xl font-bold text-red-600">
            エラーが発生しました
          </h1>
          <p className="mt-2 text-gray-600">{error.message}</p>
          {process.env.NODE_ENV === 'development' && (
            <pre className="mt-4 text-left bg-gray-100 p-4 rounded text-sm overflow-auto">
              {error.stack}
            </pre>
          )}
        </div>
      </div>
    );
  }

  return <div>不明なエラーが発生しました</div>;
}

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.slug!);

  if (!post) {
    // ErrorBoundary がキャッチする HTTP エラーをスロー
    throw new Response(null, {
      status: 404,
      statusText: '記事が見つかりません',
    });
  }

  return json({ post });
}

ルートレベルのグローバルエラーハンドリング

app/root.tsxErrorBoundaryを定義すると、すべてのルートのフォールバックとして機能します。

// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  useRouteError,
} from '@remix-run/react';

export function ErrorBoundary() {
  const error = useRouteError();

  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <title>エラー</title>
      </head>
      <body>
        <div className="min-h-screen flex items-center justify-center bg-gray-50">
          {isRouteErrorResponse(error) ? (
            <div className="text-center">
              <h1 className="text-5xl font-bold">{error.status}</h1>
              <p className="mt-2">{error.statusText}</p>
            </div>
          ) : (
            <div className="text-center">
              <h1 className="text-2xl font-bold text-red-600">
                予期しないエラー
              </h1>
            </div>
          )}
        </div>
        <Scripts />
      </body>
    </html>
  );
}

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>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

7. Optimistic UI(useFetcher・楽観的更新)

useFetcherとは

useFetcherはページ遷移を伴わずにLoaderやActionを呼び出すためのフックです。Ajaxリクエストに相当しますが、Remixのデータフローと統合されているため、キャッシュ無効化や再フェッチを自動で管理します。

Likeボタンの楽観的更新

// app/routes/posts.$id.tsx
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { useFetcher, useLoaderData } from '@remix-run/react';

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { id: params.id },
    select: { id: true, title: true, likes: true },
  });
  return json({ post });
}

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  if (intent === 'like') {
    const post = await db.post.update({
      where: { id: params.id },
      data: { likes: { increment: 1 } },
    });
    return json({ likes: post.likes });
  }

  throw new Response('不明なアクション', { status: 400 });
}

function LikeButton({ postId, likes }: { postId: string; likes: number }) {
  const fetcher = useFetcher<typeof action>();

  // 楽観的更新: サーバーレスポンスが来る前にUIを更新
  const optimisticLikes =
    fetcher.state === 'submitting'
      ? likes + 1
      : fetcher.data?.likes ?? likes;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}`}>
      <input type="hidden" name="intent" value="like" />
      <button
        type="submit"
        disabled={fetcher.state !== 'idle'}
        className="flex items-center gap-2 text-red-500 hover:text-red-600"
      >
        <span>❤</span>
        <span>{optimisticLikes}</span>
      </button>
    </fetcher.Form>
  );
}

export default function Post() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article>
      <h1>{post?.title}</h1>
      <LikeButton postId={post!.id} likes={post!.likes} />
    </article>
  );
}

インラインTodoリストの楽観的更新

// app/components/TodoItem.tsx
import { useFetcher } from '@remix-run/react';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

export function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher();

  // submitting 中は楽観的な値を使用
  const completed =
    fetcher.state === 'submitting'
      ? fetcher.formData?.get('completed') === 'true'
      : todo.completed;

  return (
    <li className={`flex items-center gap-3 p-3 rounded ${
      completed ? 'opacity-50' : ''
    }`}>
      <fetcher.Form method="post" action={`/todos/${todo.id}`}>
        <input type="hidden" name="intent" value="toggle" />
        <input
          type="hidden"
          name="completed"
          value={String(!todo.completed)}
        />
        <button
          type="submit"
          className="w-5 h-5 rounded border flex items-center justify-center"
        >
          {completed && <span>✓</span>}
        </button>
      </fetcher.Form>
      <span className={completed ? 'line-through' : ''}>{todo.title}</span>
    </li>
  );
}

8. Meta関数(SEO・OGP・動的メタデータ)

Meta関数の基本

RemixのMeta関数はルートごとに<head>タグのメタデータを動的に生成します。Loaderのデータを受け取ることができるため、記事タイトルや商品名など動的なコンテンツからSEOメタデータを生成できます。

// app/routes/blog.$slug.tsx
import type { MetaFunction, LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.slug!);
  if (!post) throw new Response(null, { status: 404 });
  return json({ post });
}

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data?.post) {
    return [{ title: '記事が見つかりません' }];
  }

  const { post } = data;
  const ogImageUrl = `https://example.com/og/${post.slug}.png`;

  return [
    // 基本SEO
    { title: `${post.title} | TechBlog` },
    { name: 'description', content: post.excerpt },

    // OGP(Open Graph Protocol)
    { property: 'og:title', content: post.title },
    { property: 'og:description', content: post.excerpt },
    { property: 'og:type', content: 'article' },
    { property: 'og:url', content: `https://example.com/blog/${post.slug}` },
    { property: 'og:image', content: ogImageUrl },
    { property: 'og:image:width', content: '1200' },
    { property: 'og:image:height', content: '630' },
    { property: 'article:published_time', content: post.publishedAt },
    { property: 'article:author', content: post.author.name },

    // Twitter Card
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: post.title },
    { name: 'twitter:description', content: post.excerpt },
    { name: 'twitter:image', content: ogImageUrl },

    // 正規URL
    { tagName: 'link', rel: 'canonical', href: `https://example.com/blog/${post.slug}` },
  ];
};

親ルートのメタデータを継承・上書き

matchesパラメータを使って親ルートのメタデータをベースに拡張できます。

export const meta: MetaFunction<typeof loader> = ({ data, matches }) => {
  // 親ルート(root.tsx)のメタデータを取得
  const parentMeta = matches.flatMap((match) => match.meta ?? []);

  // title と description を上書きして残りを継承
  const filtered = parentMeta.filter(
    (m) => !('title' in m) && !(('name' in m) && m.name === 'description')
  );

  return [
    ...filtered,
    { title: `${data?.post.title} | MyBlog` },
    { name: 'description', content: data?.post.excerpt },
  ];
};

9. Links関数(CSS読み込み・preload)

Links関数でCSSをルート単位で管理

RemixのLinks関数により、各ルートで必要なCSSやリソースを宣言できます。ページ遷移時に不要なCSSを削除し、必要なCSSを追加することでスタイルの干渉を防ぎます。

// app/routes/dashboard.tsx
import type { LinksFunction } from '@remix-run/node';
import dashboardStyles from '~/styles/dashboard.css?url';
import chartsStyles from '~/styles/charts.css?url';

export const links: LinksFunction = () => [
  // ルート固有のCSS
  { rel: 'stylesheet', href: dashboardStyles },
  { rel: 'stylesheet', href: chartsStyles },

  // リソースのプリロード
  {
    rel: 'preload',
    href: '/fonts/inter.woff2',
    as: 'font',
    type: 'font/woff2',
    crossOrigin: 'anonymous',
  },

  // ファビコン
  { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' },
];

export default function Dashboard() {
  return <div className="dashboard">...</div>;
}

グローバルスタイルはroot.tsxで管理

// app/root.tsx
import type { LinksFunction } from '@remix-run/node';
import globalStyles from '~/styles/global.css?url';
import tailwindStyles from '~/styles/tailwind.css?url';

export const links: LinksFunction = () => [
  { rel: 'stylesheet', href: tailwindStyles },
  { rel: 'stylesheet', href: globalStyles },
  {
    rel: 'preconnect',
    href: 'https://fonts.googleapis.com',
  },
];

10. Session管理(cookie・SessionStorage)

RemixはWeb標準のCookieを直接サポートし、サーバーサイドのSession管理を簡潔に実装できます。

// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node';

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  error: string;
  success: string;
};

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>({
    cookie: {
      name: '__session',
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 30, // 30日
      path: '/',
      sameSite: 'lax',
      secrets: [process.env.SESSION_SECRET!],
      secure: process.env.NODE_ENV === 'production',
    },
  });

export { getSession, commitSession, destroySession };

// ユーティリティ: ログイン状態の確認
export async function requireUserId(request: Request) {
  const session = await getSession(request.headers.get('Cookie'));
  const userId = session.get('userId');

  if (!userId) {
    throw redirect('/login', {
      headers: {
        'Set-Cookie': await destroySession(session),
      },
    });
  }

  return userId;
}

ログイン・ログアウトフローの実装

// app/routes/auth.login.tsx
import {
  json,
  redirect,
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
} from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { getSession, commitSession } from '~/lib/session.server';
import { verifyLogin } from '~/lib/auth.server';

export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));

  // すでにログイン済みの場合はダッシュボードへ
  if (session.get('userId')) {
    return redirect('/dashboard');
  }

  return json({
    flashError: session.get('error'),
  });
}

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));
  const formData = await request.formData();

  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  const user = await verifyLogin(email, password);

  if (!user) {
    session.flash('error', 'メールアドレスまたはパスワードが正しくありません');
    return json(
      { error: 'ログインに失敗しました' },
      {
        status: 401,
        headers: { 'Set-Cookie': await commitSession(session) },
      }
    );
  }

  session.set('userId', user.id);

  return redirect('/dashboard', {
    headers: { 'Set-Cookie': await commitSession(session) },
  });
}

// app/routes/auth.logout.tsx
export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'));

  return redirect('/', {
    headers: { 'Set-Cookie': await destroySession(session) },
  });
}

11. Prisma統合(DB接続・型安全クエリ)

Prismaのセットアップ

pnpm add prisma @prisma/client
pnpm prisma init
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String    @id @default(cuid())
  email     String    @unique
  name      String
  posts     Post[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  excerpt     String
  content     String
  published   Boolean   @default(false)
  publishedAt DateTime?
  author      User      @relation(fields: [authorId], references: [id])
  authorId    String
  tags        Tag[]
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

シングルトンPrismaクライアント(開発環境のホットリロード対応)

開発環境ではHMR(Hot Module Replacement)のたびにPrismaクライアントが再生成されてコネクション数が枯渇する問題があります。シングルトンパターンで対処します。

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

let prisma: PrismaClient;

declare global {
  // eslint-disable-next-line no-var
  var __db__: PrismaClient;
}

// 本番環境: 新規インスタンスを作成
// 開発環境: globalに保持してHMR時の再生成を防ぐ
if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.__db__) {
    global.__db__ = new PrismaClient({
      log: ['query', 'error', 'warn'],
    });
  }
  prisma = global.__db__;
}

export { prisma };

LoaderとActionでのPrisma活用

// app/routes/admin.posts.tsx
import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData, Form, Link } from '@remix-run/react';
import { prisma } from '~/lib/db.server';
import { requireUserId } from '~/lib/session.server';

export async function loader({ request }: LoaderFunctionArgs) {
  await requireUserId(request);

  const posts = await prisma.post.findMany({
    include: {
      author: { select: { name: true } },
      tags: { select: { name: true } },
      _count: { select: { tags: true } },
    },
    orderBy: { updatedAt: 'desc' },
  });

  return json({ posts });
}

export async function action({ request }: ActionFunctionArgs) {
  const userId = await requireUserId(request);
  const formData = await request.formData();
  const intent = formData.get('intent');
  const postId = formData.get('postId') as string;

  if (intent === 'publish') {
    await prisma.post.update({
      where: { id: postId },
      data: {
        published: true,
        publishedAt: new Date(),
      },
    });
    return json({ ok: true });
  }

  if (intent === 'delete') {
    // 権限確認: 自分の記事のみ削除可能
    const post = await prisma.post.findFirst({
      where: { id: postId, authorId: userId },
    });

    if (!post) {
      throw new Response('権限がありません', { status: 403 });
    }

    await prisma.post.delete({ where: { id: postId } });
    return redirect('/admin/posts');
  }

  throw new Response('不明なアクション', { status: 400 });
}

12. Cloudflare Workers デプロイ

なぜCloudflare Workersなのか

Cloudflare Workersはエッジコンピューティングプラットフォームです。世界300以上のデータセンターでコードが実行されるため、ユーザーに最も近い場所でレスポンスが生成されます。無料枠では1日10万リクエストまで処理でき、スタートアップや個人プロジェクトに最適です。

セットアップ

# Cloudflare Workers 対応テンプレートで作成
npx create-remix@latest --template remix-run/remix/templates/cloudflare

# または既存プロジェクトにアダプター追加
pnpm add @remix-run/cloudflare @remix-run/cloudflare-pages
pnpm add -D wrangler
// vite.config.ts(Cloudflare Pages用)
import {
  vitePlugin as remix,
  cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
} from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [
    remixCloudflareDevProxy(),
    remix({
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
});

Cloudflare環境変数とKV

// app/lib/env.server.ts(Cloudflare Workers用)
import type { AppLoadContext } from '@remix-run/cloudflare';

export function getEnv(context: AppLoadContext) {
  return context.cloudflare.env as {
    DATABASE_URL: string;
    SESSION_SECRET: string;
    KV_NAMESPACE: KVNamespace;
  };
}
// app/routes/cache-demo.tsx(KVキャッシュの活用)
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
import { getEnv } from '~/lib/env.server';

export async function loader({ request, context }: LoaderFunctionArgs) {
  const env = getEnv(context);
  const cacheKey = 'home-stats';

  // KVキャッシュからデータ取得
  const cached = await env.KV_NAMESPACE.get(cacheKey, 'json');
  if (cached) {
    return json({ ...cached, fromCache: true });
  }

  // キャッシュミス: データを取得してKVに保存
  const stats = await computeExpensiveStats();
  await env.KV_NAMESPACE.put(cacheKey, JSON.stringify(stats), {
    expirationTtl: 3600, // 1時間キャッシュ
  });

  return json({ ...stats, fromCache: false });
}

wrangler.toml設定

# wrangler.toml
name = "my-remix-app"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "./build/client"

[[kv_namespaces]]
binding = "KV_NAMESPACE"
id = "your-kv-namespace-id"

[vars]
ENVIRONMENT = "production"

デプロイコマンド

# Cloudflare Pages にデプロイ
pnpm run build
npx wrangler pages deploy ./build/client

# または GitHub Actions で CI/CD
# .github/workflows/deploy.yml に設定

13. Vercel・Fly.io デプロイ

Vercelへのデプロイ

Vercelは最もシンプルにRemixアプリをデプロイできるプラットフォームです。

# アダプターのインストール
pnpm add @remix-run/vercel

# Vercel CLI でデプロイ
pnpm add -D vercel
npx vercel
// vite.config.ts(Vercel対応は特別な設定不要)
// Remix の Vite プラグインが自動的に Vercel に対応

vercel.jsonで設定を追加します。

{
  "framework": "remix",
  "buildCommand": "remix vite:build",
  "devCommand": "remix vite:dev",
  "installCommand": "pnpm install",
  "regions": ["nrt1"],
  "env": {
    "DATABASE_URL": "@database_url",
    "SESSION_SECRET": "@session_secret"
  }
}

GitHub Actions との連携(Vercel)

# .github/workflows/deploy.yml
name: Deploy to Vercel

on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run tests
        run: pnpm test

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}

Fly.ioへのデプロイ(Node.jsランタイム)

データベースを持つフルスタックアプリにはFly.ioが適しています。Dockerコンテナとして動作し、グローバルなエッジネットワークにデプロイできます。

# flyctl のインストール
brew install flyctl

# アプリの初期化
fly launch --name my-remix-app

# PostgreSQLデータベースの作成(Fly Postgres)
fly postgres create --name my-remix-db
fly postgres attach my-remix-db
# Dockerfile
FROM node:20-slim AS base
RUN npm install -g pnpm

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build

FROM base AS production
WORKDIR /app
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma
COPY package.json ./

ENV NODE_ENV=production
EXPOSE 3000
CMD ["pnpm", "start"]
# fly.toml
app = "my-remix-app"
primary_region = "nrt"  # 東京リージョン

[build]

[http_service]
  internal_port = 3000
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 1

[[vm]]
  memory = "512mb"
  cpu_kind = "shared"
  cpus = 1
# デプロイ
fly deploy

# ログ確認
fly logs

# マイグレーション実行
fly ssh console -C "npx prisma migrate deploy"

Remix開発でのAPI検証ツール

Remixの開発では、LoaderがAPIから取得するJSONデータの構造確認や、ActionがクライアントやサードパーティAPIに送信するペイロードの検証が頻繁に必要になります。特に型安全を重視するTypeScript環境では、実際のAPIレスポンスとuseLoaderDataで期待する型が一致しているかを素早くチェックすることが生産性に直結します。

こうしたJSON APIデータの検証作業では、DevToolBox が役立ちます。DevToolBoxはブラウザ上でJSONの整形・差分比較・スキーマ検証を無料で行えるツール群を提供しています。ローカル開発中にLoaderのレスポンスをコピーしてDevToolBoxのJSONビューアーで構造を確認したり、PrismaクエリのANSI結果をJSONに変換して期待する型定義と照合する、といった使い方ができます。APIのモックデータ生成機能もあるため、バックエンドAPIが未完成の段階でUIの実装を進めたい場合にも活用できます。


まとめ

本記事ではRemixの主要機能を網羅的に解説しました。重要なポイントを整理します。

設計思想の理解

  • RemixはWeb標準(HTTP・fetch API・FormData)を最優先にする
  • Progressive Enhancementにより、JavaScriptなし環境でも基本機能が動作する
  • フレームワーク固有のAPIを少なく保ち、学習コストを下げる

コアAPI

  • Loader: サーバーサイドデータフェッチ。useLoaderData<typeof loader>()で型安全に取得
  • Action: フォーム処理。useActionDataでバリデーションエラーを表示
  • Nested Routes: ファイル構造でレイアウトとルートを宣言的に定義
  • ErrorBoundary: ルートごとのエラーハンドリング。HTTP 404/500を適切にUIに変換

高度な機能

  • useFetcher: ページ遷移なしのAjax操作。楽観的更新でUXを向上
  • Meta/Links関数: SEO・OGP・CSSを宣言的にルート単位で管理
  • Session管理: Web標準のCookieで安全なセッションを実装

デプロイ選択肢

  • Cloudflare Workers: エッジコンピューティングで超低レイテンシ・無料枠豊富
  • Vercel: 最もシンプルなデプロイ体験。Next.jsと同様の感覚で利用可能
  • Fly.io: Node.jsランタイムが必要なフルスタックアプリに最適

Remixは「Webの基礎を大切にする」というシンプルな原則から生まれたフレームワークです。一度その哲学を理解すると、APIドキュメントを読むよりも「HTTPではどう動くか」を考えることが問題解決の近道になります。フォームバリデーション・セッション管理・エラーハンドリングの多くは、Web標準の知識がそのまま応用できます。

Next.jsとRemixはどちらが優れているかではなく、プロジェクトの性質に応じて使い分けるものです。コンテンツ主体のサイトや管理画面のようなCRUDアプリにはRemixが特に強みを発揮します。ぜひ公式チュートリアル(remix.run/docs/en/main/start/tutorial)と合わせて、実際に手を動かしてみてください。

関連記事