🚀 この記事で学べること
「動くものは作れるようになった。でもこの書き方がベストなのか?」
Next.js のベストプラクティスをキャッチアップして、業務システムの基本中の基本である「マスタメンテナンス」へ詰め込みました。
- URLのクエリパラメータを使った検索・ページネーション
- データフェッチ・コロケーションとリクエストメモ化
- Zod を使った、画面(Input)とDB(Output)の型変換
- Server Actions と Repository パターンの役割分担
- decimal 型の扱いなど、実務で踏みがちな地雷の避け方
📚 参考にさせていただきました
元記事の趣旨を完全に反映できていない可能性があります。
何か誤りなどあれば、お気軽にコメントしてください。
探求中の未熟者なのでご容赦ください🙇
🔎 アプリのイメージ
認証済みユーザーのみがマスタを操作できる設計にしています。
(管理者 admin@test.com のみマスタメンテ可能)

なお認証・認可の詳細については、別記事にまとめています。
気になる方はこちらをご覧ください。
検索条件とページ番号はすべてURLのクエリパラメータで管理しています。

マスタメンテナンス用のダイアログを利用したシンプルな入力フォームです。

実際に触ってみるのが一番イメージが湧くと思います!
デモサイトを用意しました。
1.全体の構成
✅ ディレクトリ構造
(商品マスタメンテ部分だけを抜粋)
src/
├── app/(protected)/master/
│ └── product/
│ ├── _components/
│ │ ├── 商品Dialog.tsx # 【Client】入力フォームとバリデーション
│ │ ├── 商品List.tsx # 【Client】一覧表示
│ │ └── 商品ListServer.tsx # 【Server】一覧表示データフェッチ
│ ├── actions.ts # 【Server Action】バリデーション実行とリポジトリ呼び出し
│ └── page.tsx # 【Server】ページの入り口
│
└── db/
├── model/
│ └── 商品Model.ts # 【Shared】Zodによる入力検証・型変換ロジック
├── repository/
│ └── 商品Repository.ts # 【Server Only】Drizzleを使用した純粋なDB操作
└── schema.ts # 【Shared】DBテーブル定義(Drizzle Schema)
✅ 各コンポーネントの役割分担
page.tsx【Server Component】
- ページレンダリング前にセッションから厳密な認可判定
- URLクエリパラメータの取得
- Suspense を利用した 商品ListServer コンポーネントの呼び出し
商品ListServer.tsx【Server Component】
- 商品Repositoryを呼び出し、データをフェッチ
- レンダリング用のクライアントコンポーネント 商品List.tsx の呼び出し
商品List.tsx【Client Component】
- 商品マスタのリスト表示のレンダリング部分
- 商品マスタのリストは Propsで受け取る
- ページネーションの操作(前のページ/次のページ)を受付
- 検索条件の指定操作を受付
- 商品の追加、編集・確認ダイアログの状態制御
商品Dialog.tsx【Client Component】
- 商品の追加、編集・確認ダイアログ
- 登録や更新時にサーバーアクション actions.ts を呼び出し
actions.ts【Server Action】
- クライアントコンポーネントからDB操作するためのエンドポイント
- 実行前にセッションから厳密な認可判定
- サーバーサイドとして引数を必ず検証してから、リポジトリ 商品Repository.ts を呼び出す
商品Repository.ts【Server Only】※ server-only
- Drizzle を利用して実際にDBへ値を書き込む
🔤 デモアプリのコード
2.状態の管理はURLに任せる(検索・ページネーション)
- 実務ではデータ量が膨大になる可能性が高い
- 検索機能は必須
- ページネーションも必須
検索キーワード、現在のページ番号などの「状態」を 「URLのクエリパラメータ(?q=xxx&page=2)」で管理する設計 がベストプラクティス
なぜ useState ではなく URL で状態管理するのか?
検索キーワードを React の useState だけで管理すると、ブラウザの「戻る・進む」ボタンで検索条件が機能せず、他の人に検索結果のURLを共有することもできなくなる
- キーワードで一覧を絞り込み
- 登録画面へ遷移
- 登録が完了し一覧へ戻ったときに 絞り込んだ条件が消えてる!
このトラブルを完全に防げるのが大きいです
✅ page.tsx サーバーコンポーネント
(クエリパラメータの取得と認可確認)
- ページの入り口
- URLパラメータを読み取り、コンポーネントへ条件を渡す
Next.js 15 / 16 の仕様に則り、searchParams を Promise として非同期で受け取る
また、Next.js 16 から middleware.ts は proxy.ts に変更されている
import { Loader2 } from "lucide-react";
import { Suspense } from "react";
import { requireAdmin } from "@/lib/auth-guard";
import { ProductListServer } from "./_components/商品ListServer";
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
await requireAdmin();
const params = await searchParams;
return (
<main className="p-8 max-w-6xl mx-auto">
<h1 className="text-2xl font-bold mb-6">商品マスタメンテナンス</h1>
{/* 検索条件ごとにSuspenseで境界を作ることで、UXを向上させる */}
<Suspense
key={JSON.stringify(params)}
fallback={<Loader2 className="animate-spin m-auto" />}
>
<ProductListServer
query={params.q || ""}
page={Number(params.page) || 1}
/>
</Suspense>
</main>
);
}
3.データの取得と表示(コロケーションとメモ化)
- データフェッチは page.tsx ではなく表示用の各コンポーネントで行う
- データフェッチはClient Componentsではなく、Server Componentsで行う
✅ 商品ListServer.tsx (データフェッチ・コロケーション)
import { 商品Repository } from "@/db/repository/商品Repository";
import { ProductList } from "./商品List";
export async function ProductListServer({
query,
page,
}: {
query: string;
page: number;
}) {
const pageSize = env.PAGE_ROW_COUNT;
// データフェッチをコンポーネントのすぐそばで行う
const { items, totalCount } = await 商品Repository.Search(
query,
page,
pageSize,
);
return (
<ProductList
pageData={items}
totalCount={totalCount}
pageSize={pageSize}
/>
);
}
💡 なぜここでデータフェッチするのがベストなのか?
Prop Drilling の解消:
- page.tsx から何階層も Props でデータをバケツリレーする必要がなくなり、コンポーネントが自己完結する
Streaming への対応:
- page.tsx の Suspense 境界と組み合わせることで、重いDBクエリを待たずにページ全体の枠組みを先にユーザーへ見せることが可能になる
安全なリポジトリ利用:
- server-only な 商品Repository を、クライアントに露出させることなく直接サーバーから呼び出せる
データフェッチ コロケーション
- クライアントコンポーネントで利用するデータは、できるだけその直前のサーバーコンポーネントで取得する
(データのバケツリレーは可読性も保守性も低下する)
✅ リクエストメモ化(Request Memoization)
複数のコンポーネントに分割し、コロケーションでそれぞれからデータフェッチすると、重複したデータフェッチが複数同時に発生する可能性がある。
- リクエストメモ化は重複したデータフェッチを、最初の1回だけ実際に通信を行い、2回目以降はそのメモリ上の結果(キャッシュ)を使い回す
- ただし、ユーザーからの「1回のリクエスト(1回のGETやPOSTに伴うサーバー側でのレンダリングパス)」が開始されてから完了するまでの間のみ有効
この自動的な重複排除が効くのは、標準の fetch API を使った場合のみ
Drizzle ORMなどを使ったデータベースへの直接クエリは対象外!
✅ Drizzle ORMからのデータフェッチをキャッシュ化
標準のリクエストメモ化が効かない Drizzle ORM 経由のデータフェッチをリクエストメモ化と同じようにキャッシュ化できる方法がある。
import "server-only";
import { cache } from "react";
const _impl = {
// 実際のDBフェッチ(公開しない)
};
export const 商品Repository = {
// 参照系: React.cache でメモ化
Search: cache(_impl.search),
SearchById: cache(_impl.searchById),
// 更新系: キャッシュせずそのまま実行
Insert: _impl.insert,
Update: _impl.update,
Delete: _impl.delete,
};
✅ 商品List.tsx (クライアントコンポーネント)
- サーバーコンポーネントがDBから受け取ったデータをPropsで受け取り
- PropsをMap展開してテーブル表示
- 検索やページ切り替えのアクションを処理
重要な役割:
「ページの変更や検索条件の入力など、ユーザーの操作をURLのクエリパラメータへ変換すること」
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
// ... (UIコンポーネントのimportは省略)
export function ProductList({
pageData,
totalCount,
pageSize,
}: {
pageData: 商品Output[];
totalCount: number;
pageSize: number;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
// URLから現在の状態を取得
const currentPage = Number(searchParams.get("page")) || 1;
const totalPages = Math.ceil(totalCount / pageSize);
type DialogState = { open: false } | { open: true; item: 商品Output | null };
const [dialog, setDialog] = useState<DialogState>({ open: false });
// 検索処理(URLパラメータの書き換え)
const handleSearch = (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const params = new URLSearchParams(searchParams);
const keyword = formData.get("q") as string;
if (keyword) params.set("q", keyword);
else params.delete("q");
params.set("page", "1"); // 検索時は1ページ目に戻す
// トランジションで包むことで、サーバー通信中のUIフリーズを防ぐ
startTransition(() => {
router.push(`?${params.toString()}`);
});
};
// ポイントとなる部分を抜粋
return (
{/* ...中略... */}
{/* 検索ボタンのローディング制御 */}
<Button
type="submit"
className="w-full md:w-32"
disabled={isPending}
>
{isPending ? (
<Loader2 className="animate-spin h-4 w-4" />
) : (
"検索"
)}
</Button>
{/* データ表示(Zodで型安全が保証されているため toLocaleString が安全に呼べる) */}
<TableCell className="text-right pr-4">
¥{p.単価.toLocaleString()}
</TableCell>
{/* 編集ボタン(対象データをStateに入れてダイアログを開く) */}
<Button onClick={() => setDialog({ open: true, item: p })}>
<Pencil className="h-4 w-4 text-blue-600" />
</Button>
{/* ...中略... */}
)
}
URLのクエリパラメータの変更もこんなに簡単!
if (keyword) params.set("q", keyword);
else params.delete("q");
params.set("page", "1"); // 検索時は1ページ目に戻す
✨実装のポイント
(1) useTransition によるUX向上
router.push でURLを変更すると、サーバー側で再度DB検索(page.tsx の再実行)が発生する。この通信中に画面が操作不能になるのを防ぐため、startTransition でラップし、通信中は isPending を true にして検索ボタンにスピナー(Loader2)を表示している。
(2) 型安全なデータのレンダリング
一覧に表示される pageData は、DBから取得された時点で 商品Output 型であることが保証されているので、金額のカンマ区切り(p.単価.toLocaleString())などのメソッドも、型エラーの不安なく安全に呼び出すことができる。
(3) ダイアログとの連携
新規追加や編集ボタンが押された際は、DialogState に対象データをセットして 商品Dialog.tsx を呼び出し、登録・更新(Write)のフローへ繋ぐ。
✅ 登録・更新用ダイアログ 商品Dialog.tsxの呼び出し
新規追加の場合
<Button
onClick={() => setDialog({ open: true, item: null })}
>
</Button>
更新の場合
<Button
onClick={() => setDialog({ open: true, item: p })}
>
</Button>
商品一覧ページ内にあらかじめダイアログを用意しておく
{dialog.open && (
<ProductDialog
target={dialog.item}
onClose={() => setDialog({ open: false })}
/>
)}
なぜURL管理ではなく useState なのか?
検索条件とは異なり、「どのデータを編集しているか」や「ダイアログが開いているか」という状態は一時的な状態なので、ブラウザの「戻る」ボタンで戻ったりURLで誰かに共有したりする必要性が低いため、通常の useState で管理している。
4.型変換を含む複雑な入力チェックの実装
簡単なデモアプリとは異なり、実務の実装を始めると型変換チェックなどで困ることが出てくる。
✅ Drizzle用のDBテーブル スキーマ(最終的な保管ルール)
export const 商品 = pgTable("商品", {
商品CD: text("商品CD").primaryKey(),
商品名: text("商品名").notNull(),
単価: decimal("単価").notNull(),
備考: text("備考"),
version: integer("version").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
✅ 入力チェック用の 商品Model スキーマ(ブラウザからの橋渡し)
- 画面入力時は、必須項目も一時的に空文字になりえる
- 画面入力時は、数値項目にも一時的に文字列が入る可能性がある
入力時に認める型(Input)から、最終的な型(Output)までの変換ルールをZodで記述する。
import { z } from "zod";
import { nonNegativeNumericSchema } from "./共通チェック";
export const 商品Model = z.object({
商品CD: z.string().min(1, "必須"),
商品名: z.string().min(1, "必須"),
単価: nonNegativeNumericSchema("単価は必須です"),
備考: z.string().optional().nullable(),
version: z.number().default(0),
});
// 入力時の型(フォーム管理・UI用)
export type 商品Input = z.input<typeof 商品Model>;
// 出力時の型(アプリケーションロジック・完成品)
export type 商品Output = z.output<typeof 商品Model>;
import { z } from "zod";
export const nonNegativeNumericSchema = (requiredMsg: string) =>
z
// DB取得は number、画面入力は string で来るため両方を受け付ける
.union([z.number(), z.string()])
.refine((v) => String(v).trim() !== "", requiredMsg)
// ここで number へ強制変換
.pipe(z.coerce.number())
.refine((v) => !Number.isNaN(v), "数値を入力してください")
.refine((v) => v >= 0, "0以上で入力してください");
メソッドチェーンの順番が重要。
.pipe(z.coerce.number()) が先に実行されると、スペースのみの入力や空文字は数値の 0 へ自動変換されてしまう。
空文字を 0 扱いされては困るため、先に .refine で文字列としての空判定を行い、空の場合はエラーで弾く。
schema.ts と 商品Model.ts の違い
DBのテーブル定義(schema.ts)には数値型などの厳密な「型」や not null などの制約がある。
しかしブラウザでエンドユーザーが入力中は、型や制約を守っている保証はない。
「何でも入る状態」から「正しい型」へ安全に橋渡しするための定義として 商品Model.ts を記述している。
✅ クライアントコンポーネントでの利用
useForm にてスキーマの指定
- Formが扱う型は入力用の「商品Input」
- resolverはバリデーションと型変換を含む「商品Model」
const form = useForm<商品Input>({
resolver: zodResolver(商品Model),
defaultValues: target // 既存データの修正の場合
? { ...target, 備考: target.備考 ?? "" } // nullの可能性がある列は空文字に変換
: {
// 新規登録の場合
商品CD: "",
商品名: "",
単価: 0,
備考: "",
version: 0,
},
});
useForm<商品Input>
フォームが扱うデータの型を指定。商品Input は Zod の z.input<...> から生成された型で、「フォームに入力される値の型(変換前)」。
resolver: zodResolver(商品Model)
バリデーションロジックの注入。handleSubmit を呼んだとき、商品Model(Zodスキーマ)でバリデーションを実行するよう指示している。「どのライブラリでバリデーションするか」のアダプター。
defaultValues
フォームの初期値。ここが少し注意で、単なる「初期値」以上の役割がある。
React Hook Formは defaultValues を基準に「変更されたか(isDirty)」を判定するので、正確な初期値を渡すことが重要。
{ ...target, 備考: target.備考 ?? "" } // nullの可能性がある列は空文字に変換
なぜ null を空文字("")に変換するのか?
DBから取得したデータが null のまま React Hook Form の defaultValues に渡されると、Reactが「非制御コンポーネント」として認識してしまい、コンソールに警告(Warning)が出る。
これを防ぐため、入力UI側では必ず ""(空文字)として扱っている。
入力結果の取得
- useFormで指定した「商品Input」型でまずは受け取る
- zodResolver によりsubmit前に検証が行われる(クライアント側での再parseは不要)
5.DBへの書き込みの流れと役割分担
✅ 依存関係図とデータの流れ
データの流れ(ライフサイクル)
| 階層 | 役割 |
|---|---|
| クライアント コンポーネント | ユーザーがデータを入力する |
| サーバーアクション | 届いたデータを Zod Model で検証し、合格すればDBリポジトリを呼び出す |
| DBリポジトリ | 変換済みのデータを Drizzle 経由でSQLとして発行する |
クライアントコンポーネントから直接DBへ書き込むのは厳禁。DBへの接続情報が漏洩するリスクや、悪意のあるユーザーからの保護が極めて難しくなる。
クライアントからはサーバーアクションを呼び出す(ブラウザからはPOSTする)だけ。実際のDB操作はサーバー内に隠蔽する。
✅ サーバーアクション
- 自動でエンドポイントへ変換され POST を受け取る(Next.js の強み!)
- クライアントコンポーネントからは
awaitで呼び出す(内部的には POST が発生するため非同期になる) - 悪意のあるクライアントを意識し、必ず認可判定する
- 悪意のあるクライアントを意識し、クライアントからの情報を必ずサーバー側でもう一度バリデートする
サーバーサイドではクライアントからの情報を決して信頼しない。
たとえ引数に data: 商品Input と型を指定していても、ネットワークを越えて届く実体は単なる JSON。悪意のあるユーザーが型定義を無視したデータを直接送信してくる可能性を常に考慮する必要がある。
そのため、サーバー側で 商品Model.parse(data) を実行し、改めてバリデーションと型変換を行うことが必須。
「入力の受け口」を許容度の高い Input型 で定義し、内部で Parse して「信頼できる Output型」に昇華させることで、後続のDB処理などを安全に実行できる。
処理の流れ
- 認可判定(マスタメンテはAdmin権限が必要)
- 受け取ったデータのバリデーション
- DBへの insert/update など DBリポジトリの呼び出しと実行
- エラーハンドリング
- DB書き換えによるReactへキャッシュ破棄依頼(DB修正結果を画面へ反映させるために重要)
"use server";
import { revalidatePath } from "next/cache";
import { ZodError } from "zod";
import { 商品Input, 商品Model } from "@/db/model/商品Model";
import { 商品Repository } from "@/db/repository/商品Repository";
import { requireAdmin } from "@/lib/auth-guard";
export async function save商品(data: 商品Input, isEdit: boolean) {
// 認可判定
await requireAdmin();
try {
// クライアントから情報を信頼しない。
//(data: 商品Input としているが、実際に受け取るのはただのJSON)
// サーバーサイドとして、受け取った情報が商品Modelとして適切か確認する
const validated = 商品Model.parse(data);
if (isEdit) {
await 商品Repository.Update(
validated.商品CD,
validated.version,
validated,
);
} else {
const result = await 商品Repository.Insert(validated);
if (result.length === 0) {
return { success: false, error: "商品が既に存在します" };
}
}
// 現在表示中の商品情報キャッシュの破棄
revalidatePath("/master/product");
return { success: true };
} catch (e: unknown) {
console.error("Save Error:", e);
// Zodのバリデーションエラー
if (e instanceof ZodError) {
return {
success: false,
error: "入力内容に不備があります。画面の指示に従ってください。",
};
}
// 楽観的排他ロックの失敗など、
// Error インスタンスであれば、そのメッセージをフロントに返す
if (e instanceof Error) {
return {
success: false,
error: e.message,
};
}
// 予期せぬエラー(ネットワーク切断など)の場合のフォールバック
return {
success: false,
error: "予期せぬエラーが発生しました。時間をおいて再度お試しください。",
};
}
}
revalidatePath("/master/product")
指定パスのキャッシュを破棄することで、DBへ更新を反映した最新の結果が取得される
✅ 認可判定(BetterAuthの活用)
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
// 認証ガード
export async function requireSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/login");
}
return session;
}
// 認証+認可(admin)ガード
export async function requireAdmin() {
// 認証チェック
const session = await requireSession();
// 認可エラーはルートページへリダイレクト
if (session.user.role !== "admin") {
redirect("/");
}
return session;
}
✅ DBリポジトリ(実際のDBへのInsert処理)
import "server-only"; // クライアント側に混入したらビルドエラーにする
async Insert(データ: 商品Output) {
return await db
.insert(商品)
.values({
...データ,
単価: データ.単価.toString(), // 浮動小数点の桁落ち防止
version: 0, // 初期バージョン
})
.onConflictDoNothing({ target: 商品.商品CD })
.returning();
},
Drizzle ORM では numeric / decimal 型は TS 上で string として扱う仕様。
JS の number のまま渡すと型エラーになるほか、浮動小数点の桁落ちや指数表記化(1e+21)によるパースエラーのリスクがある。
✅ DBリポジトリ(実際のDBへのUpdate処理)
async Update(
商品CD: string,
現在のversion: number,
データ: Omit<商品Output, "商品CD" | "version">,
) {
const result = await db
.update(商品)
.set({
...データ,
単価: データ.単価.toString(), // 浮動小数点の桁落ち防止
version: 現在のversion + 1,
updatedAt: new Date(),
})
.where(and(eq(商品.商品CD, 商品CD), eq(商品.version, 現在のversion)));
// rowCount 0 は「where句に一致しなかった = 他者が更新済み」を意味する
if (result.rowCount === 0) {
throw new Error(
"対象のデータは別のユーザーによって更新されたか、削除されています。",
);
}
return result;
},
✅ 入力画面へのDB処理結果の通知
- 処理結果はトースト(Toast)で通知する
- 「追加時」は、同一の Primary Key を入力した2重キーエラーがありえる(この場合は「商品CD」の入力欄にエラーメッセージを直接表示する)
- 「修正時」は、他の操作者が先に更新した楽観的排他エラーがありえる
- その他のネットワーク障害などによるエラーの可能性もある
エラー判定の実装について
このデモアプリでは可読性を優先し、サーバー側から返されるエラーメッセージ(文字列)で直接二重キーエラーを判定している。
if (res.error === "商品が既に存在します")
実際の業務開発では、多言語化やメッセージ変更の影響を受けないよう、サーバー側でエラーコード(例: ERR_DUPLICATE_KEY)を定義し、コードベースで判定する設計を推奨します。
const onSubmit = async (data: 商品Input) => {
const res = await save商品(data, isEdit);
// DB処理結果の通知
if (res.success) {
toast.success(isEdit ? "更新しました" : "登録しました");
onClose();
} else {
// エラーメッセージの内容で商品CDの重複か判定する
if (res.error === "商品が既に存在します") {
form.setError("商品CD", {
type: "manual",
message: res.error,
});
} else {
// 排他制御など、全体に関するエラーはトーストで表示
toast.error(res.error || "保存に失敗しました");
}
}
};
🚀 まとめ
僕自身も探求中の身ですが、React/Next.js(App Router)のベストプラクティスをできるだけキャッチアップして、業務アプリケーションへ落とし込んでみました。
今回構築した設計思想やテンプレートが、皆さんの素晴らしいプロダクト開発の一助になれば幸いです!
✨ さらなる拡張
マスタメンテから発展させた親子関係のある「受注データ」の起票に関しては別記事にまとめてます。気になる方はそちらもご覧ください。