Next.js vs Remix: 適切なReactフレームワークの選び方

Shunku

Next.jsとRemixはフルスタックアプリケーション構築のための2つの主要なReactフレームワークです。それぞれの違いを理解することで、プロジェクトに適したツールを選択できます。

フレームワークの哲学

flowchart TD
    subgraph Next["Next.jsの哲学"]
        A[React-first] --> B[Server Components]
        B --> C[Edge Runtime]
        C --> D[Vercel統合]
    end

    subgraph Remix["Remixの哲学"]
        E[Web標準] --> F[プログレッシブ エンハンスメント]
        F --> G[ネストルート]
        G --> H[フォームミューテーション]
    end

    style Next fill:#000,color:#fff
    style Remix fill:#3992ff,color:#fff
側面 Next.js Remix
哲学 Reactのイノベーション Web標準
データフェッチ Server Components Loaders
ミューテーション Server Actions Actions
フォーム プログレッシブ エンハンスメントはオプション プログレッシブ エンハンスメントがコア
デプロイ Vercel最適化 プラットフォーム非依存

ルーティング比較

Next.js App Router

app/
├── page.tsx                    # /
├── about/
│   └── page.tsx                # /about
├── blog/
│   ├── page.tsx                # /blog
│   └── [slug]/
│       └── page.tsx            # /blog/:slug
└── (marketing)/
    └── pricing/
        └── page.tsx            # /pricing
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{post.content}</article>;
}

Remixルート

app/
├── routes/
│   ├── _index.tsx              # /
│   ├── about.tsx               # /about
│   ├── blog._index.tsx         # /blog
│   ├── blog.$slug.tsx          # /blog/:slug
│   └── _marketing.pricing.tsx  # /pricing(レイアウト付き)
└── root.tsx
// app/routes/blog.$slug.tsx
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';

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

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

  return <article>{post.content}</article>;
}

データフェッチ

Next.js: Server Components

// app/users/page.tsx
// コンポーネントで直接データフェッチ
export default async function UsersPage() {
  const users = await fetch('https://api.example.com/users').then((r) =>
    r.json()
  );

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Remix: Loaders

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

export async function loader() {
  const users = await fetch('https://api.example.com/users').then((r) =>
    r.json()
  );
  return json({ users });
}

export default function UsersPage() {
  const { users } = useLoaderData<typeof loader>();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

主な違い

側面 Next.js Remix
場所 コンポーネント内 別のloader関数
戻り値の型 コンポーネントのprops シリアライズされたJSON
キャッシュ 組み込みfetchキャッシュ 手動ヘッダー
並列 コンポーネントでPromise.all ネストルートで自動

フォーム処理

Next.js: Server Actions

// app/actions.ts
'use server';

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  await db.users.create({ name });
  revalidatePath('/users');
}

// app/users/page.tsx
import { createUser } from './actions';

export default function UsersPage() {
  return (
    <form action={createUser}>
      <input name="name" required />
      <button type="submit">作成</button>
    </form>
  );
}

Remix: Actions

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

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

  await db.users.create({ name });

  return redirect('/users');
}

export default function UsersPage() {
  return (
    <Form method="post">
      <input name="name" required />
      <button type="submit">作成</button>
    </Form>
  );
}

プログレッシブエンハンスメント

// Remix: JavaScriptなしでも動作
<Form method="post">
  <input name="name" />
  <button>送信</button>
</Form>

// Next.js: 完全な機能にはクライアントサイドの強化が必要
'use client';

import { useActionState } from 'react';
import { createUser } from './actions';

export function CreateUserForm() {
  const [state, formAction] = useActionState(createUser, null);

  return (
    <form action={formAction}>
      <input name="name" />
      <button>送信</button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

エラーハンドリング

Next.js

// app/users/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>ユーザーの読み込みエラー</h2>
      <button onClick={reset}>もう一度試す</button>
    </div>
  );
}

Remix

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

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

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h2>{error.status} {error.statusText}</h2>
      </div>
    );
  }

  return (
    <div>
      <h2>ユーザーの読み込みエラー</h2>
    </div>
  );
}

ネストレイアウト

Next.js

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  );
}

// app/dashboard/page.tsx
export default function DashboardPage() {
  return <h1>ダッシュボード</h1>;
}

// app/dashboard/settings/page.tsx
export default function SettingsPage() {
  return <h1>設定</h1>;
}

Remix

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

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>
        <Outlet />
      </main>
    </div>
  );
}

// app/routes/dashboard._index.tsx
export default function DashboardPage() {
  return <h1>ダッシュボード</h1>;
}

// app/routes/dashboard.settings.tsx
export default function SettingsPage() {
  return <h1>設定</h1>;
}

ローディング状態

Next.js

// app/dashboard/loading.tsx
export default function Loading() {
  return <Skeleton />;
}

// またはSuspenseで
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <SlowComponent />
    </Suspense>
  );
}

Remix

// app/routes/dashboard.tsx
import { useNavigation } from '@remix-run/react';

export default function Dashboard() {
  const navigation = useNavigation();
  const isLoading = navigation.state === 'loading';

  return (
    <div>
      {isLoading && <LoadingSpinner />}
      <Outlet />
    </div>
  );
}

メタタグ / SEO

Next.js

// app/blog/[slug]/page.tsx
import { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.image],
    },
  };
}

Remix

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

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  return [
    { title: data.post.title },
    { name: 'description', content: data.post.excerpt },
    { property: 'og:title', content: data.post.title },
    { property: 'og:image', content: data.post.image },
  ];
};

Next.jsを選ぶべき時

Next.jsが理想的なのは:

  • Vercelデプロイ: Vercelプラットフォームとの最適な統合
  • React Server Components: 最先端のReact機能が欲しい
  • 画像最適化: 組み込みの画像処理が必要
  • 段階的な導入: 既存のReactアプリからの移行
  • 大規模なエコシステム: 豊富なプラグインエコシステムが必要
  • 静的生成: 静的サイト要件が重要
// Next.jsの強み
import Image from 'next/image';

export default function Gallery() {
  return (
    <div>
      {/* 自動画像最適化 */}
      <Image
        src="/photo.jpg"
        width={800}
        height={600}
        alt="写真"
        placeholder="blur"
      />
    </div>
  );
}

Remixを選ぶべき時

Remixが理想的なのは:

  • プログレッシブエンハンスメント: フォームはJSなしでも動作する必要がある
  • Web標準: 標準API(fetch、FormData)を好む
  • プラットフォームの柔軟性: どこにでもデプロイ(Vercelだけでなく)
  • ネストルーティング: データを持つ複雑なネストレイアウト
  • エラーハンドリング: きめ細かなエラー境界
  • 楽観的UI: 組み込みの楽観的更新パターン
// Remixの強み: プログレッシブエンハンスメント
import { useFetcher } from '@remix-run/react';

export function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher();
  const isLiking = fetcher.state !== 'idle';

  return (
    <fetcher.Form method="post" action="/like">
      <input type="hidden" name="postId" value={postId} />
      <button disabled={isLiking}>
        {isLiking ? 'いいね中...' : 'いいね'}
      </button>
    </fetcher.Form>
  );
}

機能比較

機能 Next.js Remix
Server Components ✅ ネイティブ ❌ まだ
ストリーミングSSR ✅ 組み込み ✅ 組み込み
静的生成 ✅ 強力 ⚠️ 限定的
画像最適化 ✅ 組み込み ❌ 外部
Edge Runtime ✅ ネイティブ ✅ サポート
プログレッシブエンハンスメント ⚠️ オプション ✅ コア
ネストルートデータ ✅ パラレルルート ✅ 自動
プラットフォームロックイン ⚠️ Vercel最適化 ✅ 非依存

移行の考慮事項

Next.jsからRemixへ

// Next.js
export default async function Page() {
  const data = await getData();
  return <div>{data}</div>;
}

// Remix相当
export async function loader() {
  const data = await getData();
  return json({ data });
}

export default function Page() {
  const { data } = useLoaderData<typeof loader>();
  return <div>{data}</div>;
}

RemixからNext.jsへ

// Remix
export async function loader() {
  return json({ users: await getUsers() });
}

export default function Users() {
  const { users } = useLoaderData<typeof loader>();
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// Next.js相当
export default async function Users() {
  const users = await getUsers();
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

まとめ

側面 Next.js Remix
最適な用途 Vercel、RSC、静的サイト Web標準、フォーム
データフェッチ コンポーネント内async Loader関数
ミューテーション Server Actions Action関数
哲学 Reactのイノベーション プログレッシブエンハンスメント
デプロイ Vercel最適化 プラットフォーム非依存
学習曲線 中程度 中程度

重要なポイント:

  • Next.jsはReact Server ComponentsとVercel統合でリード
  • Remixはweb標準とプログレッシブエンハンスメントを優先
  • 両方がストリーミングSSRとネストレイアウトをサポート
  • Next.jsはより良い静的生成と画像最適化を持つ
  • Remixはより強力なプログレッシブエンハンスメントとフォーム処理
  • Vercelデプロイと最先端のReactにはNext.jsを選択
  • プラットフォームの柔軟性とWeb標準重視にはRemixを選択
  • 両方とも本番Reactアプリケーションの優れた選択肢

最良の選択はチームの優先事項、デプロイのニーズ、好みの開発パターンによります。

参考文献