ブログ記事

Remix v3完全ガイド 2025 - フルスタックWebフレームワークの新時代

Remix v3の衝撃的な方向転換とReact Router v7への移行パスを徹底解説。Vite統合、React Server Components対応、Next.jsとの比較まで、最新のフルスタック開発手法を実践的に紹介します。

10分で読めます
R
Rina
Daily Hack 編集長
Web開発
Remix React フルスタック Web開発 フレームワーク
Remix v3完全ガイド 2025 - フルスタックWebフレームワークの新時代のヒーロー画像

2025 年 5 月、Remix チームは Web 開発コミュニティに衝撃を与える発表を行いました。Remix v3はReactを完全に放棄し、Preactのフォークを採用するという大胆な方向転換です。一方で、現在の Remix ユーザーには React Router v7 への明確な移行パスが用意されています。

重要な方向転換

Remix v3 は予想外の進化を遂げ、Reactから独立した新しいフレームワークとして生まれ変わります。既存の Remix プロジェクトは React Router v7 への移行が推奨されています。

Remixの進化と現状

Remix誕生

React Routerチームが新しいフルスタックフレームワークを発表

Shopify買収

Remixチームがクラウドプラットフォームから独立

Vite統合

デフォルトコンパイラーとしてViteを採用

React Router v7

RemixとReact Routerが統合

Remix v3発表

Reactから独立、Preactフォークを採用

現在のRemix(v2.x)+ Vite

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

# 最新のRemixプロジェクトを作成(Vite使用)
npx create-remix@latest my-app

# テンプレートを指定
npx create-remix@latest --template remix-run/remix/templates/vite

# Express + Vite
npx create-remix@latest --template remix-run/remix/templates/vite-express

# Cloudflare Pages + Vite
npx create-remix@latest --template remix-run/remix/templates/vite-cloudflare

Vite設定

// 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 flagsを有効化(React Router v7への準備)
      future: {
        v3_fetcherPersist: true,
        v3_relativeSplatPath: true,
        v3_throwAbortReason: true,
      },
    }),
    tsconfigPaths(),
  ],
  // サーバーサイドの最適化
  ssr: {
    noExternal: ["@remix-run/*"],
  },
  // ビルド最適化
  build: {
    target: "esnext",
    minify: true,
    sourcemap: true,
  },
});

データフェッチングとアクション

Loader/Actionパターン

// React + useEffect
function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <Spinner />;
  if (error) return <Error />;
  
  return <ProductGrid products={products} />;
}
// Remixのloader
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader({ request }) {
  // サーバーサイドで実行
  const url = new URL(request.url);
  const category = url.searchParams.get("category");
  
  const products = await db.products.findMany({
    where: category ? { category } : undefined,
    include: { reviews: true }
  });
  
  // 自動的にキャッシュヘッダーを設定
  return json(products, {
    headers: {
      "Cache-Control": "max-age=300, s-maxage=3600"
    }
  });
}

export default function ProductList() {
  // エラーハンドリングとローディングは自動
  const products = useLoaderData<typeof loader>();
  
  return <ProductGrid products={products} />;
}
従来のデータフェッチング
// React + useEffect
function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);
  
  if (loading) return <Spinner />;
  if (error) return <Error />;
  
  return <ProductGrid products={products} />;
}
Remixのアプローチ
// Remixのloader
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader({ request }) {
  // サーバーサイドで実行
  const url = new URL(request.url);
  const category = url.searchParams.get("category");
  
  const products = await db.products.findMany({
    where: category ? { category } : undefined,
    include: { reviews: true }
  });
  
  // 自動的にキャッシュヘッダーを設定
  return json(products, {
    headers: {
      "Cache-Control": "max-age=300, s-maxage=3600"
    }
  });
}

export default function ProductList() {
  // エラーハンドリングとローディングは自動
  const products = useLoaderData<typeof loader>();
  
  return <ProductGrid products={products} />;
}

高度なデータパターン

// app/routes/dashboard.tsx
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";

export async function loader({ request }: LoaderFunctionArgs) {
  // 認証チェック
  const user = await requireUser(request);
  
  // 並列でデータを取得
  const [stats, recentOrders, notifications] = await Promise.all([
    getStats(user.id),
    getRecentOrders(user.id),
    getNotifications(user.id)
  ]);
  
  // 遅延ローディングも可能
  const analyticsPromise = getAnalytics(user.id);
  
  return defer({
    user,
    stats,
    recentOrders,
    notifications,
    analytics: analyticsPromise // ストリーミング
  });
}

export default function Dashboard() {
  const { user, stats, recentOrders, notifications, analytics } = 
    useLoaderData<typeof loader>();
  
  return (
    <div className="dashboard">
      <h1>Welcome, {user.name}!</h1>
      
      <StatsGrid stats={stats} />
      <RecentOrders orders={recentOrders} />
      <NotificationList notifications={notifications} />
      
      {/* 遅延ローディングコンテンツ */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Await resolve={analytics}>
          {(data) => <Analytics data={data} />}
        </Await>
      </Suspense>
    </div>
  );
}
// app/routes/products.$id.edit.tsx
import { 
  json, 
  redirect,
  unstable_createMemoryUploadHandler,
  unstable_parseMultipartFormData,
} from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";

export async function action({ request, params }: ActionFunctionArgs) {
  const productId = params.id;
  
  // マルチパートフォームデータの処理
  const uploadHandler = unstable_createMemoryUploadHandler({
    maxPartSize: 5_000_000, // 5MB
  });
  
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  );
  
  // バリデーション
  const errors = validateProductForm(formData);
  if (errors) {
    return json({ errors }, { status: 400 });
  }
  
  try {
    // データベース更新
    const product = await db.products.update({
      where: { id: productId },
      data: {
        name: formData.get("name"),
        price: Number(formData.get("price")),
        description: formData.get("description"),
        image: await processImage(formData.get("image")),
      },
    });
    
    // 成功時はリダイレクト
    return redirect(`/products/${product.id}`);
  } catch (error) {
    return json(
      { error: "商品の更新に失敗しました" },
      { status: 500 }
    );
  }
}

export default function EditProduct() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
  
  return (
    <Form method="post" encType="multipart/form-data">
      <fieldset disabled={isSubmitting}>
        <label>
          商品名
          <input 
            name="name" 
            required
            aria-invalid={actionData?.errors?.name ? true : undefined}
          />
          {actionData?.errors?.name && (
            <span className="error">{actionData.errors.name}</span>
          )}
        </label>
        
        <label>
          価格
          <input 
            name="price" 
            type="number" 
            required
            min="0"
          />
        </label>
        
        <label>
          画像
          <input 
            name="image" 
            type="file" 
            accept="image/*"
          />
        </label>
        
        <button type="submit">
          {isSubmitting ? "保存中..." : "保存"}
        </button>
      </fieldset>
      
      {actionData?.error && (
        <div className="error-message">{actionData.error}</div>
      )}
    </Form>
  );
}
// app/routes/stream.tsx
import { 
  createReadableStreamFromReadable 
} from "@remix-run/node";
import { Readable } from "stream";

export async function loader() {
  // カスタムストリームの作成
  const stream = new Readable({
    read() {}
  });
  
  // 非同期でデータをストリーミング
  streamData(stream);
  
  return new Response(
    createReadableStreamFromReadable(stream),
    {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
      },
    }
  );
}

async function streamData(stream: Readable) {
  // リアルタイムデータのストリーミング
  for (let i = 0; i < 100; i++) {
    await new Promise(resolve => setTimeout(resolve, 100));
    
    const data = {
      id: i,
      timestamp: new Date().toISOString(),
      value: Math.random() * 100
    };
    
    stream.push(`data: ${JSON.stringify(data)}\n\n`);
  }
  
  stream.push(null); // ストリーム終了
}

// クライアント側
export default function StreamingData() {
  const [data, setData] = useState<any[]>([]);
  
  useEffect(() => {
    const eventSource = new EventSource("/stream");
    
    eventSource.onmessage = (event) => {
      const newData = JSON.parse(event.data);
      setData(prev => [...prev, newData]);
    };
    
    return () => eventSource.close();
  }, []);
  
  return (
    <div>
      <h2>リアルタイムデータ</h2>
      <div className="data-stream">
        {data.map(item => (
          <div key={item.id}>
            {item.timestamp}: {item.value.toFixed(2)}
          </div>
        ))}
      </div>
    </div>
  );
}

エラーハンドリング

ErrorBoundaryとCatchBoundary

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

export function ErrorBoundary() {
  const error = useRouteError();
  
  if (isRouteErrorResponse(error)) {
    return (
      <html>
        <head>
          <title>{error.status} {error.statusText}</title>
          <Meta />
          <Links />
        </head>
        <body>
          <div className="error-container">
            <h1>{error.status}</h1>
            <p>{error.statusText}</p>
            {error.data && <pre>{JSON.stringify(error.data, null, 2)}</pre>}
          </div>
          <Scripts />
        </body>
      </html>
    );
  }
  
  // 予期しないエラー
  const errorMessage = error instanceof Error 
    ? error.message 
    : "Unknown error";
  
  return (
    <html>
      <head>
        <title>エラーが発生しました</title>
        <Meta />
        <Links />
      </head>
      <body>
        <div className="error-container">
          <h1>申し訳ございません</h1>
          <p>予期しないエラーが発生しました。</p>
          <details>
            <summary>詳細情報</summary>
            <pre>{errorMessage}</pre>
          </details>
        </div>
        <Scripts />
      </body>
    </html>
  );
}

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

Remix の強力な特徴の 1 つは、JavaScriptが無効でも動作するプログレッシブエンハンスメントです。

プログレッシブエンハンスメントの仕組み

チャートを読み込み中...

// プログレッシブエンハンスメントの例
import { Form, useFetcher } from "@remix-run/react";

export default function Newsletter() {
  const fetcher = useFetcher();
  const isSubmitting = fetcher.state === "submitting";
  
  // JavaScriptが無効でも動作する
  return (
    <section>
      <h2>ニュースレター登録</h2>
      
      {/* プログレッシブエンハンスメント対応フォーム */}
      <fetcher.Form method="post" action="/newsletter">
        <input
          type="email"
          name="email"
          placeholder="メールアドレス"
          required
          disabled={isSubmitting}
        />
        
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? "登録中..." : "登録"}
        </button>
      </fetcher.Form>
      
      {/* 成功メッセージ */}
      {fetcher.data?.success && (
        <p className="success">登録ありがとうございます!</p>
      )}
      
      {/* エラーメッセージ */}
      {fetcher.data?.error && (
        <p className="error">{fetcher.data.error}</p>
      )}
    </section>
  );
}

React Server Components対応

2025 年 5 月、React Router で RSC のプレビューサポートが開始されました。

// app/routes/_rsc.products.tsx
// Server Component Route

export async function loader() {
  const products = await db.products.findMany();
  
  // RSCとして返す
  return (
    <ProductList products={products}>
      <SomeExpensiveComponent />
    </ProductList>
  );
}

// app/components/ProductList.tsx
'use client';

import { useState } from 'react';

export function ProductList({ products, children }) {
  const [filter, setFilter] = useState('');
  
  return (
    <div>
      <input 
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="商品を検索"
      />
      
      {/* Server Componentの子要素 */}
      {children}
      
      {/* クライアントサイドのフィルタリング */}
      {products
        .filter(p => p.name.includes(filter))
        .map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
    </div>
  );
}

Next.js App Routerとの比較

Remix vs Next.js App Router比較(2025年6月)
機能 Remix Next.js App Router 評価
データフェッチング loader/action Server Components 引き分け
ルーティング ネステッド優先 ファイルシステム Remix ⭐
フォーム処理 ネイティブサポート Server Actions Remix ⭐
プログレッシブエンハンスメント 完全対応 部分的 Remix ⭐
静的生成 なし フル対応 Next.js ⭐
エッジランタイム 対応 対応 引き分け
開発体験 シンプル 複雑 Remix ⭐
エコシステム 成長中 成熟 Next.js ⭐

パフォーマンス比較

Remix - 動的コンテンツ 95 %
Next.js - 動的コンテンツ 85 %
Remix - 静的コンテンツ 75 %
Next.js - 静的コンテンツ 98 %

React Router v7への移行

既存の Remix アプリケーションは、React Router v7 への移行が推奨されています。

移行手順

# 1. Future Flagsを有効化
# remix.config.js
module.exports = {
  future: {
    v3_fetcherPersist: true,
    v3_relativeSplatPath: true,
    v3_throwAbortReason: true,
  },
};

# 2. 依存関係の更新
npm uninstall @remix-run/node @remix-run/react @remix-run/serve
npm install react-router@7 @react-router/node @react-router/serve

# 3. インポートの更新(自動化可能)
npx codemod remix-to-react-router

ルート設定の移行

// app/routes.ts (新しい形式)
import { 
  type RouteConfig,
  route,
  index,
  layout,
  prefix,
} from "@react-router/dev/routes";

export default [
  layout("layouts/main.tsx", [
    index("routes/home.tsx"),
    route("about", "routes/about.tsx"),
    route("products/:id", "routes/products.$id.tsx"),
    
    prefix("admin", [
      layout("layouts/admin.tsx", [
        index("routes/admin/index.tsx"),
        route("users", "routes/admin/users.tsx"),
        route("settings", "routes/admin/settings.tsx"),
      ]),
    ]),
  ]),
] satisfies RouteConfig;

Remix v3の新しい方向性

Remix v3の哲学

  • Demand Composition: 単一目的で置換可能な抽象化
  • シンプルさと明確さ: 複雑さを排除し、理解しやすいコード
  • パフォーマンス最優先: Preact ベースで軽量化
  • モジュラーツールキット: 必要な機能のみを選択
// Remix v3の予想される構文(Preactベース)
import { h } from '@remix-run/core';
import { Router, Route } from '@remix-run/router';

export function App() {
  return (
    <Router>
      <Route path="/" component={Home} />
      <Route path="/about" component={About} />
      <Route path="/products/:id" loader={loadProduct}>
        {(props) => <Product {...props} />}
      </Route>
    </Router>
  );
}

// より軽量なコンポーネント
export function Home() {
  return h('div', { class: 'home' }, [
    h('h1', null, 'Welcome to Remix v3'),
    h('p', null, 'Blazing fast with Preact'),
  ]);
}

デプロイメント

// app/entry.server.tsx (Cloudflare Workers)
import { RemixServer } from "@remix-run/react";
import { renderToReadableStream } from "react-dom/server";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  const body = await renderToReadableStream(
    <RemixServer context={remixContext} url={request.url} />,
    {
      signal: request.signal,
      onError(error: unknown) {
        console.error(error);
        responseStatusCode = 500;
      },
    }
  );

  responseHeaders.set("Content-Type", "text/html");
  
  return new Response(body, {
    headers: responseHeaders,
    status: responseStatusCode,
  });
}

// wrangler.toml
name = "remix-app"
main = "build/index.js"
compatibility_date = "2024-01-01"

[site]
bucket = "./public"

[build]
command = "npm run build"
// vercel.json
{
  "functions": {
    "app/entry.server.tsx": {
      "runtime": "nodejs20.x"
    }
  },
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/app/entry.server.tsx"
    }
  ]
}

// package.json scripts
{
  "scripts": {
    "build": "remix build",
    "vercel-build": "remix build && cp -r public .vercel/output/static"
  }
}
# Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:20-alpine

WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./

RUN npm ci --production

EXPOSE 3000
CMD ["npm", "start"]

# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://...
    volumes:
      - ./data:/app/data

ベストプラクティスとTips

Remix開発のベストプラクティス

  1. loader/actionを最大限活用: クライアントサイドの state 管理を最小限に
  2. プログレッシブエンハンスメント: JavaScript無効でも動作する設計
  3. エラーバウンダリー: 各ルートでエラー処理を実装
  4. キャッシュ戦略: loader 内で Cache-Control ヘッダーを適切に設定
  5. 並列データフェッチ: Promise.all で複数のデータソースを効率的に取得

Remix は、Web プラットフォームの基本に立ち返ることで、より良い開発体験とユーザー体験を提供します。フレームワークの複雑さではなく、Web の仕組みを理解することが重要です。

Ryan Florence Remix共同創業者

まとめ

Remix は大きな転換期を迎えています。現在の v2.x は Vite統合により素晴らしい開発体験を提供し、React Router v7 への移行パスも明確です。一方、Remix v3 の大胆な方向転換は、Web 開発の未来に新たな可能性を示しています。

プロジェクトの要件に応じて、適切な選択をすることが重要です:

  • 新規プロジェクト: React Router v7 の採用を検討
  • 既存Remixプロジェクト: v7 への段階的移行
  • 実験的プロジェクト: Remix v3 の動向を注視
Rinaのプロフィール画像

Rina

Daily Hack 編集長

フルスタックエンジニアとして10年以上の経験を持つ。 大手IT企業やスタートアップでの開発経験を活かし、 実践的で即効性のある技術情報を日々発信中。 特にWeb開発、クラウド技術、AI活用に精通。

この記事は役に立ちましたか?

あなたのフィードバックが記事の改善に役立ちます

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。