Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割

Remix アプリケーションを開発する際、プロジェクトの規模が大きくなるにつれて、ファイルやディレクトリの整理が課題になってきます。初期段階では問題なく管理できていたコードも、機能が増えるとファイルが散らばり、保守性が低下してしまうことは少なくありません。
本記事では、Remix プロジェクトにおいて「routes」「リソース」「ユーティリティ」の 3 つの観点からディレクトリを分割し、スケーラブルな設計を実現する方法を解説します。これにより、コードの見通しが良くなり、チーム開発でもスムーズに作業できる環境が整うでしょう。
背景
Remix におけるファイルベースルーティング
Remix はファイルベースルーティングを採用しており、app/routes
ディレクトリ内のファイル構造が URL パスに直接対応します。例えば、app/routes/posts.tsx
は /posts
というルートに、app/routes/posts.$id.tsx
は /posts/:id
に対応するのです。
この仕組みは非常にシンプルでわかりやすい反面、プロジェクトが成長するとルートファイルが増え続け、ディレクトリ構造が複雑になりやすいという特性があります。
以下の図は、Remix のファイルベースルーティングの基本的な対応関係を示しています。
mermaidflowchart LR
file1["app/routes/_index.tsx"] -->|対応| route1["/"]
file2["app/routes/posts.tsx"] -->|対応| route2["/posts"]
file3["app/routes/posts.$id.tsx"] -->|対応| route3["/posts/:id"]
file4["app/routes/about.tsx"] -->|対応| route4["/about"]
このように、ファイル名とディレクトリ構造が URL に直結するため、構造設計が重要になります。
プロジェクト成長時の課題
小規模なプロジェクトでは、すべてのルートファイルを app/routes
直下に置いても問題ありません。しかし、以下のような状況になると管理が困難になってきます。
- ルートファイルが 20 個、30 個と増えていく
- 各ルートで使う共通ロジックやユーティリティが散在する
- API ルートとページルートが混在し、区別がつきにくい
- データ取得やビジネスロジックがルートファイル内に集中し、ファイルが肥大化する
こうした問題に対処するには、明確な設計方針と分割戦略が必要です。
課題
ルートファイルの肥大化
Remix のルートファイルには、loader
、action
、コンポーネントレンダリングロジックなど、複数の責務が集中しがちです。これにより、1 つのファイルが数百行に達し、可読性が低下してしまいます。
typescript// app/routes/posts.$id.tsx の例(アンチパターン)
export async function loader({
params,
}: LoaderFunctionArgs) {
// データ取得ロジックが直接記述される
const post = await db.post.findUnique({
where: { id: params.id },
});
return json({ post });
}
typescriptexport async function action({
request,
}: ActionFunctionArgs) {
// 更新ロジックも同じファイル内に記述
const formData = await request.formData();
// ... 複雑な処理
}
typescriptexport default function Post() {
// コンポーネントのレンダリングロジック
const { post } = useLoaderData<typeof loader>();
return <div>{/* ... 長い JSX */}</div>;
}
上記のように、すべてを 1 つのファイルに詰め込むと、テストやリファクタリングが困難になります。
コード重複の発生
複数のルートで同じようなデータ取得処理や検証ロジックを使う場合、それぞれのルートファイルにコピー&ペーストしてしまうと、保守性が著しく低下します。
例えば、認証チェックや権限確認、共通のエラーハンドリングなどが各ルートに散らばると、修正時に複数箇所を変更しなければなりません。
API ルートとページルートの混在
Remix では、同じ routes
ディレクトリ内に、画面を返すページルートと、JSON を返す API ルートを配置できます。しかし、両者が混在すると、どのファイルが何の役割を持つのか一目で判断しにくくなります。
ルートファイル | 役割 | 返却内容 |
---|---|---|
app/routes/posts.tsx | ページ | HTML |
app/routes/api.posts.tsx | API | JSON |
このように名前で区別することは可能ですが、ファイル数が増えると管理が煩雑になります。
ユーティリティ関数の配置場所が不明確
共通ロジックやヘルパー関数をどこに置くべきか、チーム内で統一されていないと、同じような関数が複数の場所に重複して作られてしまいます。
以下の図は、課題が発生する典型的なディレクトリ構造を示しています。
mermaidflowchart TD
routes["app/routes/"]
routes --> route1["posts.tsx<br/>(肥大化)"]
routes --> route2["posts.$id.tsx<br/>(肥大化)"]
routes --> route3["api.posts.tsx"]
routes --> route4["about.tsx"]
route1 -.->|重複ロジック| duplicate["データ取得<br/>検証<br/>エラー処理"]
route2 -.->|重複ロジック| duplicate
route3 -.->|重複ロジック| duplicate
duplicate -.->|問題| problem["保守性低下<br/>可読性低下"]
このような状態では、新しい機能追加や既存機能の修正が非常に難しくなります。
解決策
ディレクトリ構造の 3 層分割
スケーラブルな Remix プロジェクトを実現するには、以下の 3 つの層に分けてディレクトリを設計することが有効です。
# | 層 | 役割 | 配置場所 |
---|---|---|---|
1 | Routes(ルート) | URL エンドポイントの定義とルーティング | app/routes/ |
2 | Resources(リソース) | ビジネスロジック、データ取得、API 呼び出し | app/models/ , app/services/ |
3 | Utilities(ユーティリティ) | 汎用的なヘルパー関数、共通処理 | app/utils/ , app/lib/ |
この分割により、各ファイルの責務が明確になり、コードの再利用性と可読性が向上します。
以下の図は、改善後のディレクトリ構造とデータフローを示しています。
mermaidflowchart TD
routes["Routes Layer<br/>(app/routes/)"]
resources["Resources Layer<br/>(app/models/, app/services/)"]
utilities["Utilities Layer<br/>(app/utils/, app/lib/)"]
routes -->|データ取得を委譲| resources
resources -->|共通処理を利用| utilities
routes -.->|役割| r1["URL ルーティング<br/>loader/action 定義<br/>コンポーネント描画"]
resources -.->|役割| r2["ビジネスロジック<br/>DB アクセス<br/>外部 API 呼び出し"]
utilities -.->|役割| r3["汎用ヘルパー<br/>検証関数<br/>フォーマッター"]
それぞれの層の具体的な設計方法を、以下で詳しく見ていきましょう。
Routes Layer:ルートファイルの役割を限定する
ルートファイルは、URL エンドポイントの定義と、loader
・action
・コンポーネントのエントリポイントに徹するべきです。ビジネスロジックやデータ取得の詳細は、Resources Layer に委譲します。
typescript// app/routes/posts.$id.tsx(改善後)
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
typescript// リソース層から関数をインポート
import { getPostById } from '~/models/post.server';
typescript// loader はデータ取得を委譲するだけ
export async function loader({
params,
}: LoaderFunctionArgs) {
const post = await getPostById(params.id);
return json({ post });
}
typescript// コンポーネントは表示ロジックに集中
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
このように、ルートファイルは薄く保ち、具体的な処理は別の層に分離します。
Resources Layer:ビジネスロジックとデータ取得を集約
データベースアクセスや外部 API 呼び出し、ビジネスロジックは、app/models/
や app/services/
ディレクトリに配置します。これにより、複数のルートから同じロジックを再利用できるようになります。
Models ディレクトリの活用
データベース操作やデータモデルに関連する処理は、app/models/
に配置します。
typescript// app/models/post.server.ts
import { db } from '~/utils/db.server';
typescript// 投稿を ID で取得する関数
export async function getPostById(id: string) {
const post = await db.post.findUnique({
where: { id },
include: { author: true },
});
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return post;
}
typescript// 投稿一覧を取得する関数
export async function getPosts() {
return db.post.findMany({
orderBy: { createdAt: 'desc' },
});
}
typescript// 投稿を作成する関数
export async function createPost(data: {
title: string;
content: string;
authorId: string;
}) {
return db.post.create({ data });
}
Services ディレクトリの活用
外部 API との連携や、複数のモデルをまたがる複雑な処理は、app/services/
に配置します。
typescript// app/services/notification.server.ts
typescript// 通知を送信するサービス
export async function sendNotification(
userId: string,
message: string
) {
// 外部 API への POST リクエスト
const response = await fetch(
'https://api.example.com/notify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, message }),
}
);
return response.json();
}
このように、ビジネスロジックを集約することで、テストやメンテナンスが容易になります。
Utilities Layer:汎用的なヘルパー関数を配置
フォーマット処理、検証ロジック、定数定義など、アプリケーション全体で使う汎用的な関数は、app/utils/
や app/lib/
に配置します。
typescript// app/utils/validation.ts
typescript// メールアドレスの検証
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
typescript// 必須フィールドの検証
export function validateRequired(
value: unknown,
fieldName: string
): string | null {
if (
!value ||
(typeof value === 'string' && value.trim() === '')
) {
return `${fieldName} is required`;
}
return null;
}
typescript// app/utils/format.ts
typescript// 日付フォーマット
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
これらの関数は、プロジェクト全体で一貫して使用でき、変更時も一箇所の修正で済みます。
ディレクトリ構造の全体像
以下は、3 層分割を適用した具体的なディレクトリ構造の例です。
phpapp/
├── routes/ # Routes Layer
│ ├── _index.tsx # トップページ
│ ├── posts.tsx # 投稿一覧
│ ├── posts.$id.tsx # 投稿詳細
│ ├── posts.new.tsx # 新規投稿
│ └── api.posts.tsx # API エンドポイント
│
├── models/ # Resources Layer(データモデル)
│ ├── post.server.ts # 投稿関連の処理
│ └── user.server.ts # ユーザー関連の処理
│
├── services/ # Resources Layer(サービス)
│ ├── notification.server.ts
│ └── auth.server.ts
│
└── utils/ # Utilities Layer
├── validation.ts # 検証関数
├── format.ts # フォーマット関数
└── db.server.ts # DB 接続設定
ディレクトリ | 用途 | 例 |
---|---|---|
app/routes/ | ルーティングとエンドポイント | posts.tsx , api.posts.tsx |
app/models/ | データベース操作 | post.server.ts |
app/services/ | 外部 API、複合処理 | notification.server.ts |
app/utils/ | 汎用ヘルパー | validation.ts , format.ts |
図で理解できる要点:
- Routes はエンドポイントの定義に専念し、ビジネスロジックは持たない
- Models と Services がデータとロジックを担当
- Utils は全レイヤーから利用される共通処理を提供
具体例
ブログアプリケーションへの適用
実際のブログアプリケーションを例に、3 層分割をどのように適用するか見ていきましょう。
ディレクトリ構成
phpapp/
├── routes/
│ ├── _index.tsx
│ ├── posts._index.tsx
│ ├── posts.$id.tsx
│ ├── posts.new.tsx
│ └── api.posts.tsx
├── models/
│ └── post.server.ts
├── services/
│ └── email.server.ts
└── utils/
├── validation.ts
└── db.server.ts
Routes Layer の実装例
まず、投稿一覧ページのルートファイルを作成します。
typescript// app/routes/posts._index.tsx
import { json } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';
typescript// モデル層から関数をインポート
import { getPosts } from '~/models/post.server';
import { formatDate } from '~/utils/format';
typescript// loader はデータ取得を委譲
export async function loader() {
const posts = await getPosts();
return json({ posts });
}
typescript// コンポーネントは表示に専念
export default function PostsIndex() {
const { posts } = useLoaderData<typeof loader>();
return (
<div>
<h1>投稿一覧</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>
{post.title}
</Link>
<span>{formatDate(post.createdAt)}</span>
</li>
))}
</ul>
</div>
);
}
次に、新規投稿ページを作成します。
typescript// app/routes/posts.new.tsx
import {
json,
redirect,
type ActionFunctionArgs,
} from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
typescriptimport { createPost } from '~/models/post.server';
import { validateRequired } from '~/utils/validation';
typescript// action で入力を検証し、モデル層に委譲
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const errors = {
title: validateRequired(title, 'タイトル'),
content: validateRequired(content, '本文'),
};
if (errors.title || errors.content) {
return json({ errors }, { status: 400 });
}
await createPost({ title, content, authorId: 'user123' });
return redirect('/posts');
}
typescriptexport default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method='post'>
<div>
<label htmlFor='title'>タイトル</label>
<input type='text' id='title' name='title' />
{actionData?.errors?.title && (
<p>{actionData.errors.title}</p>
)}
</div>
<div>
<label htmlFor='content'>本文</label>
<textarea id='content' name='content' />
{actionData?.errors?.content && (
<p>{actionData.errors.content}</p>
)}
</div>
<button type='submit'>投稿する</button>
</Form>
);
}
Resources Layer の実装例
データベース操作をまとめたモデルファイルを作成します。
typescript// app/models/post.server.ts
import { db } from '~/utils/db.server';
typescript// 投稿一覧を取得
export async function getPosts() {
return db.post.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
createdAt: true,
},
});
}
typescript// ID で投稿を取得
export async function getPostById(id: string) {
const post = await db.post.findUnique({
where: { id },
});
if (!post) {
throw new Response('投稿が見つかりません', {
status: 404,
});
}
return post;
}
typescript// 新規投稿を作成
export async function createPost(data: {
title: string;
content: string;
authorId: string;
}) {
return db.post.create({
data: {
title: data.title,
content: data.content,
authorId: data.authorId,
},
});
}
外部サービス連携をサービスファイルに分離します。
typescript// app/services/email.server.ts
typescript// 投稿作成時にメール通知を送る
export async function notifyNewPost(
postTitle: string,
authorEmail: string
) {
// 外部メール API への送信処理
console.log(
`新しい投稿: ${postTitle} を ${authorEmail} に通知しました`
);
}
Utilities Layer の実装例
汎用的な検証関数とフォーマット関数を用意します。
typescript// app/utils/validation.ts
typescript// 必須フィールド検証
export function validateRequired(
value: unknown,
fieldName: string
): string | null {
if (
!value ||
(typeof value === 'string' && value.trim() === '')
) {
return `${fieldName}は必須です`;
}
return null;
}
typescript// 文字数制限検証
export function validateMaxLength(
value: string,
maxLength: number,
fieldName: string
): string | null {
if (value.length > maxLength) {
return `${fieldName}は${maxLength}文字以内で入力してください`;
}
return null;
}
typescript// app/utils/format.ts
typescript// 日付を日本語形式でフォーマット
export function formatDate(date: Date | string): string {
const d =
typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
以下の図は、具体例における各層の連携を示しています。
mermaidsequenceDiagram
participant User as ユーザー
participant Route as Routes<br/>(posts.new.tsx)
participant Model as Models<br/>(post.server.ts)
participant Utils as Utils<br/>(validation.ts)
participant DB as データベース
User->>Route: フォーム送信
Route->>Utils: validateRequired()
Utils-->>Route: 検証結果
Route->>Model: createPost()
Model->>DB: INSERT
DB-->>Model: 完了
Model-->>Route: 作成済み投稿
Route-->>User: リダイレクト
図で理解できる要点:
- ユーザーからのリクエストは Routes が受け取る
- 検証は Utils、データ操作は Models に委譲される
- 各層が明確に分離され、責務が明確になっている
API ルートの分離
API エンドポイントとページルートを明確に区別するため、API ルートには api.
プレフィックスを付けることが推奨されます。
typescript// app/routes/api.posts.tsx
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
typescriptimport { getPosts } from '~/models/post.server';
typescript// JSON を返す API エンドポイント
export async function loader({
request,
}: LoaderFunctionArgs) {
const posts = await getPosts();
return json({ posts });
}
このファイルは /api/posts
というパスでアクセス可能になり、JSON データを返します。ページルートと分離されているため、役割が明確です。
ルート | パス | 返却形式 | 用途 |
---|---|---|---|
posts.tsx | /posts | HTML | ページ表示 |
api.posts.tsx | /api/posts | JSON | データ API |
テストの容易化
3 層分割により、各層を独立してテストできるようになります。
typescript// models/post.server.test.ts
import { describe, it, expect } from 'vitest';
import { getPostById } from './post.server';
typescriptdescribe('getPostById', () => {
it('存在する投稿を取得できる', async () => {
const post = await getPostById('123');
expect(post).toBeDefined();
expect(post.id).toBe('123');
});
it('存在しない投稿は 404 エラーを投げる', async () => {
await expect(getPostById('invalid')).rejects.toThrow();
});
});
ルートファイルから独立しているため、モデル層のテストが簡潔に書けます。
まとめ
本記事では、Remix プロジェクトにおけるスケーラブルなディレクトリ設計として、「routes」「リソース」「ユーティリティ」の 3 層分割を提案しました。
設計の重要ポイント
- Routes Layer: URL ルーティングとエントリポイントに徹し、ビジネスロジックは持たない
- Resources Layer: データ取得とビジネスロジックを
models
とservices
に集約し、再利用性を高める - Utilities Layer: 汎用的なヘルパー関数を
utils
に配置し、プロジェクト全体で一貫して使用する
この設計により、以下のメリットが得られます。
# | メリット | 説明 |
---|---|---|
1 | 可読性の向上 | 各ファイルの責務が明確になり、コードが追いやすくなる |
2 | 保守性の向上 | ロジックが集約され、変更時の影響範囲が限定される |
3 | テストの容易化 | 各層を独立してテストでき、品質が向上する |
4 | チーム開発の効率化 | 構造が明確で、新メンバーも理解しやすい |
プロジェクトの初期段階からこの設計を取り入れることで、将来的な拡張にも柔軟に対応できる基盤が整います。小規模なプロジェクトであっても、この構造を意識しておくことで、成長時のリファクタリングコストを大幅に削減できるでしょう。
ぜひ、次の Remix プロジェクトで実践してみてください。
関連リンク
- article
Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割
- article
Remix ルーティング早見表:ネスト・可変パラメータ・モーダルルート対応一覧
- article
Remix 最短セットアップ:初期化から初デプロイまで 10 分で完走する手順
- article
Remix と Next.js/Vite/徹底比較:選ぶべきポイントはここだ!
- article
【実測検証】Remix vs Next.js vs Astro:TTFB/LCP/開発体験を総合比較
- article
【2025 年完全版】Remix の特徴・メリット・適用領域を総まとめ
- article
GPT-5 失敗しないエージェント設計:プランニング/自己検証/停止規則のアーキテクチャ
- article
Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割
- article
Preact でスケーラブルな状態管理:Signals/Context/外部ストアの責務分離
- article
Emotion チートシート:css/styled/Global/Theme の即戦力スニペット 20
- article
Playwright テストデータ設計のベストプラクティス:分離・再現・クリーニング戦略
- article
NotebookLM の始め方:アカウント準備から最初のノート作成まで完全ガイド
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来