🌿 中級⏱ 所要時間の目安:半日〜2日使うツール:Claude Code / Next.js / SQLite / Drizzle ORM

データ保存つきWebアプリをAIエージェントと作る【2026】Next.js+SQLiteで社内の備品・予約管理

「総務のExcelで管理している備品の貸出、誰がどれを持っているか分からない」——そんな社内のちょっとした困りごとは、データを保存できるWebアプリにすると一気に片付きます。この記事では、Claude Codeと一緒に社内の備品貸出管理アプリ(一覧・登録・貸出/返却・検索)をNext.js+SQLiteで作る過程を、AIとの実際のやりとりの順番どおりに再現します。外部の有料APIは一切使いません。

🔬
調査・編集:AIエージェント研究所 編集部
運営=広告運用・生成AIエージェント導入支援の現役コンサル。このサイト自体も、AIエージェントと一緒に作り・公開・運用しています。

現場でAIエージェントと開発している進め方をベースに、再現できる手順とコードで解説します。→ 運営者・編集方針について

公開:2026年9月26日/最終更新:2026年9月26日
この記事の結論(要点まとめ)
  • いきなり「アプリを作って」と頼まない。①仕様メモをAIと作る → ②Plan Modeで構成案 → ③小さく実装 → ④動かして確認 → ⑤バグ報告 → ⑥テストで再発防止の順が、手戻りが最も少ない
  • 構成はNext.js(App Router)+SQLite+ORM(今回はDrizzle)。ORMの選択はAIに比較させて理由を説明させ、決めるのは人
  • 「動いた」で終わらせない。サーバー側の入力チェック・ログイン・バックアップ・置き場所(SQLiteはサーバーレスと相性が悪い)を決めてから社内に公開する

完成イメージ:こんなアプリを作ります

作るのは、社内LANの中で動く「備品貸出管理」アプリです。ブラウザで開くと、次のような画面になります。

備品貸出管理
プロジェクター検索
プロジェクター A(プロジェクター)/3F倉庫 貸出可 [借りる人][返却予定日]貸し出す
プロジェクター B(プロジェクター)/会議室2 貸出中:山田さん(10/3返却予定) 返却する

※画面イメージ。実際の見た目はAIへの指示やCSSで変わります。

  • 一覧・検索:備品名やカテゴリで絞り込み、貸出可/貸出中がひと目で分かる
  • 登録:総務が備品(名前・カテゴリ・保管場所)を追加
  • 貸出/返却:借りる人と返却予定日を入れて「貸し出す」。返すときは「返却する」を押すだけ
  • 履歴:誰が・いつ借りて・いつ返したかがDBに残る

裏側の仕組みはシンプルです。ブラウザのボタンを押すと、Next.jsのサーバー側の関数(Server Action)が動き、SQLiteという1つのファイルでできたデータベースに書き込みます。

サーバー側(Next.js)あなたのPC① ブラウザ貸し出すを押す=Client Component② Server Actionzodで入力チェック=サーバーで実行③ Drizzle ORMSQLに変換=DBとの通訳④ SQLitedata/app.db=データベース送信処理SQL保存した結果が画面に戻る
ボタンを押すと、Next.jsのサーバー側の関数(Server Action)が入力をzodでチェックして貸出処理を行い、Drizzle ORMがTypeScriptの命令をSQLに通訳して、SQLiteの1ファイル(data/app.db)に保存します。

「フロントエンド」「バックエンド」「データベース」という言葉にまだ自信がない方は、先にIT基礎シリーズ 第3章 バックエンド入門をざっと読んでおくと、AIの説明がぐっと理解しやすくなります(シリーズ全体はIT基礎ロードマップ)。

準備:Claude Codeとプロジェクトフォルダ

必要なものは次の3つです。

必要なもの内容確認方法
Node.js(LTS版)Next.jsを動かす実行環境。公式サイトからLTS版をインストールnode -v でバージョンが出ればOK
Claude Codeターミナルで動くAnthropicのコーディングエージェント。npm install -g @anthropic-ai/claude-code 等でインストール(利用プラン・料金は公式で確認)作業フォルダで claude と打つと起動
Git変更履歴を残す道具。AIの変更が気に入らないとき「1つ前に戻す」ための保険git --version
ターミナルbash
# 作業用フォルダを作ってClaude Codeを起動
mkdir -p ~/work && cd ~/work
claude
Cursor・Codexなど他のAIエージェントでも同じ流れでOK この記事はClaude Codeで説明しますが、Cursor(エディタ型)やOpenAIのCodexなど、ファイルを読み書きしてコマンドを実行できるコーディングエージェントなら、ほぼ同じプロンプトで進められます。操作名(Plan Modeなど)は各ツールの呼び方に読み替えてください。

STEP1 まず「仕様メモ」をAIと一緒に作る

データを保存するアプリで一番多い失敗は、作り始めてから「あ、履歴も残したかった」「総務だけ削除できるようにしたかった」と気づくことです。データの形(テーブル)が後から変わると、画面も処理も連鎖的に直すことになります。そこで最初の依頼は「作って」ではなく「質問して」です。

プロンプト①:仕様を固めるための質問をさせるClaude Code
社内の備品貸出を管理するWebアプリを作りたいです。
今は総務がExcelで管理していて、誰が何を借りているか分からなくなりがちです。

まだコードは書かないでください。
まず、仕様を決めるために必要な質問を10個以内でしてください。
特に「保存するデータの項目」「誰が何をできるか」「どこで動かすか」を確認したいです。
回答したら、その内容を docs/spec.md に仕様メモとしてまとめてください。

Claude Codeがしたこと:コードには触れず、「備品の数と種類は?」「貸出時に記録したい項目は(借りる人・返却予定日・用途など)?」「返却済みの履歴は残しますか?」「登録・削除できる人を限定しますか?」「社内LANだけで使いますか、社外からも使いますか?」「ログインは必要ですか?」といった質問を並べてきます。

これに箇条書きで答えると、docs/spec.md が作られます。実際に今回まとまった仕様メモはこちらです。

docs/spec.mdMarkdown
# 備品貸出管理アプリ 仕様メモ(v0.2)

## 目的
- 総務の共有Excelで管理している備品(PC・プロジェクター・カメラ等 約80点)の貸出状況を、
  社員が自分で確認・貸出・返却できるようにする

## 利用者
- 社員(約40人):一覧・検索・貸出・返却
- 総務(2人):備品の登録・編集・削除、貸出履歴の確認

## 必須機能(v1)
1. 備品一覧(名前・カテゴリ・保管場所・状態)と、名前/カテゴリでの検索
2. 備品の登録(名前・カテゴリ・保管場所は必須)
3. 貸出(借りる人・返却予定日)/返却
4. 貸出履歴(誰が・いつ借りて・いつ返したか)

## やらないこと(v1)
- 会議室の時間予約、メール通知、スマホアプリ化

## 前提・制約
- 社内LAN内の1台のPCで動かす(外部公開しない)
- 外部の有料APIやクラウドDBは使わない。データは1ファイル(SQLite)
- 同じ備品が同時に二重貸出されないこと

## 未決事項
- ログインをどうするか(→ 構成案と一緒に選択肢を出してもらう)
エージェント博士
エージェント博士ポイントは「やらないこと」と「未決事項」を書いておくことじゃ。AIは親切だから、放っておくと通知機能やらグラフやらを勝手に足しがち。範囲を文字で残しておけば、あとで「仕様メモの範囲外なので作らないで」と一言で止められるんじゃよ

STEP2 Plan Modeで構成案を出させる(ORMはどれ?)

次に、Claude CodeのPlan Modeに切り替えます(Shift+Tabで切り替え)。Plan Modeでは、AIはファイルを変更せず、「何を・どの順で・どう作るか」の計画だけを提示します。データ構造やライブラリ選びのように「後から変えにくい判断」は、必ずここで確認します。

プロンプト②:Plan Modeで構成案(Shift+Tabで切替後)Claude Code
docs/spec.md を読んで、構成案を出してください。条件:
- Next.js(App Router)+ TypeScript
- DBはSQLite(1ファイル。外部のクラウドDBは使わない)
- DBを操作するORMは Prisma と Drizzle ORM などから候補を2〜3個比較し、
  この案件ならどれを選ぶか「理由」と「デメリット」をセットで説明してください
- テーブル設計(列と型)、画面一覧、ファイル構成、作業の順番も
- ログインについては選択肢を3つ程度、メリット・注意点つきで
まだコードは書かないでください。

Claude Codeが返した構成案(要約):テーブルはitems(備品)とloans(貸出履歴)の2つ。貸出中かどうかはitems.statusに持ち、返却日時が空のloans行を「貸出中の記録」とする。画面は「一覧+検索」「備品登録」「貸出履歴」の3つ。ORMの比較は次のとおりでした。

候補特徴この案件でのメリットデメリット・注意
Prisma専用のスキーマファイル(schema.prisma)から型付きクライアントを生成。管理画面(Prisma Studio)もある情報が多く、初心者向けの解説も豊富。マイグレーションの流れが分かりやすい生成ステップ(prisma generate)が挟まる。専用の書き方を覚える必要がある
Drizzle ORMテーブル定義をTypeScriptで書く。SQLに近い書き方で、軽量定義がTSファイルなので型がそのまま使える。SQLに近く、生成されるSQLを想像しやすい。better-sqlite3と組み合わせやすいSQLの基本を知らないと読みにくい部分がある。バージョンアップで書き方が変わることがある
ORMなし(better-sqlite3で生SQL)SQL文を直接書く依存が最少。仕組みが一番透明型を自分で書く必要。テーブル変更の管理(マイグレーション)も手作り

Claude Codeの推薦はDrizzleで、理由は「テーブル2つの小規模アプリで、定義をTypeScriptに一本化でき、SQLに近いので“何が起きているか”を人が確認しやすい」。今回はこれを採用しました。Prismaを選んでも間違いではありません。チームにPrisma経験者がいる、Prisma Studioで総務がデータを直接見たい、といった事情があればPrismaが合います。

助手アイ
助手アイAIが「Drizzleがおすすめです」と言ったら、そのまま従えばいいんじゃないんですか?
エージェント博士
エージェント博士結論だけもらうと、困ったときに自分で判断できんのじゃ。「理由」と「デメリット」をセットで出させると、あとで「Prismaにしておけば…」となったときも、何を捨てて何を取ったのか説明できる。決めるのは人、材料を集めるのはAI、という分担じゃな

計画に納得したら「この計画で進めてください。ただしSTEPごとに止まって報告して」と伝えます。一度に全部作らせるより、区切りごとに動作確認できるほうが結果的に速く進みます。

STEP3 雛形作成とCLAUDE.md

Plan Modeを抜けて(もう一度Shift+Tab)、雛形を作らせます。Claude Codeは次のようなコマンドを実行します。

ターミナル(Claude Codeが実行したコマンド)bash
# 雛形を作る(質問にはTypeScript・ESLint・App Router・src/ディレクトリを「Yes」で)
npx create-next-app@latest bihin-kanri
cd bihin-kanri

# DB・入力チェック・テストの部品を追加
npm install drizzle-orm better-sqlite3 zod server-only
npm install -D drizzle-kit @types/better-sqlite3 vitest vite-tsconfig-paths

# テーブル定義からマイグレーションSQLを作り、DBに反映
mkdir -p data
npx drizzle-kit generate
npx drizzle-kit migrate

# 開発サーバーを起動 → ブラウザで http://localhost:3000
npm run dev

npx create-next-app@latest は対話形式でいくつか質問してきます(TypeScriptを使うか、ESLint、Tailwind CSS、src/ディレクトリ、App Routerなど。質問内容はバージョンによって変わります)。AIに実行させる場合は「TypeScript・App Router・src/ディレクトリを使う設定で」と伝えておけば、適切なオプションを付けて実行してくれます。

雛形ができたら、/init でプロジェクトの説明書 CLAUDE.md を作らせ、自分たちのルールを書き足します。CLAUDE.mdは、Claude Codeが起動するたびに読み込む「このリポジトリのルール・手順書」です。

CLAUDE.mdMarkdown
# CLAUDE.md(備品貸出管理)

## 概要
Next.js(App Router)+ SQLite(better-sqlite3)+ Drizzle ORM の社内向けWebアプリ。仕様は docs/spec.md。

## コマンド
- 開発サーバー: npm run dev(http://localhost:3000)
- テスト: npx vitest run
- マイグレーション: npx drizzle-kit generate → npx drizzle-kit migrate

## ルール
- DBアクセスはサーバー側(Server Component / Server Action / src/lib)だけ。Client Componentから src/db を import しない
- 入力は必ずサーバー側で zod で検証する
- テーブル定義を変えたら必ずマイグレーションを生成する。data/app.db を直接編集・削除しない
- 仕様にない機能を勝手に足さない。迷ったら質問する
- 変更したら npx vitest run と npm run lint を通してから報告する

そしてここで一度 git commit。以降も「動く状態になったらコミット」を習慣にします。AIが大きく壊しても、コミットまで戻ればやり直せます。

STEP4 DBと画面を作らせ、localhost:3000で確認

プロンプト③:DBと一覧・貸出・返却を作るClaude Code
計画のSTEP1〜2を実装してください。
- src/db/schema.ts に items と loans を定義し、マイグレーションを生成・適用
- 一覧+検索(トップページ)、貸出フォーム、返却ボタン
- 貸出・返却の処理は src/lib/loans.ts にまとめ、Server Action から呼ぶ
- 入力チェックは zod でサーバー側に
- 動作確認用に、備品を5件入れる seed スクリプトも
終わったら npm run dev で起動し、確認手順を教えてください。

Claude Codeがしたこと:次のファイルを作成・編集し、npx drizzle-kit generate と npx drizzle-kit migrate でDB(data/app.db)を作成、seedスクリプトで5件投入、最後に npm run dev を実行しました。

  • src/db/schema.ts(テーブル定義)/src/db/index.ts(DB接続)/drizzle.config.ts
  • src/lib/loans.ts(貸出・返却ロジック)/src/app/actions.ts(Server Action)
  • src/app/page.tsx(一覧+検索)/src/app/LendForm.tsx(貸出フォーム)
  • scripts/seed.ts(サンプルデータ)/.gitignoreへの追記

画面の結果:ブラウザで http://localhost:3000 を開くと、5件の備品が並び、「プロジェクター」で検索すると2件に絞り込まれました。「貸し出す」を押すと「山田 さんに貸し出しました」と表示され、ボタンが「返却する」に変わります。ページを再読み込みしても状態が残っている=データがDBに保存されていることが確認できます。

見た目の調整はスクリーンショットで伝えると速い Claude Codeには画像を貼り付けられます。「この画面のスクショの、貸出中の行を薄い赤背景にして」「参考にしたいのはこのレイアウト(画像)」のように、言葉で説明しにくい見た目は画像で伝えるのが近道です。

STEP5 バグ報告 → 修正

総務の人に試してもらうと、さっそく2つの問題が見つかりました。AIへのバグ報告は、再現手順・期待した動き・実際の動きの3点セットで書くと、推測で見当違いの箇所を直されることが減ります。

プロンプト④:バグ報告(3点セット)Claude Code
バグが2つあります。原因を調べて、直す前に原因と修正方針を説明してください。

【バグ1】同じ備品が2人に貸し出される
再現手順:2つのブラウザで同じ備品の一覧を開き、ほぼ同時に「貸し出す」を押す
期待:後から押した方は「貸出中です」とエラーになる
実際:両方成功し、loans に貸出中の記録が2件できる

【バグ2】返却後、別のタブの一覧が古いまま
再現手順:タブAで返却 → タブBで一覧を再読み込み
期待:貸出可になっている
実際:貸出中のまま(数回読み込むと直る)

Claude Codeの調査結果:バグ1の原因は、最初の実装が「①状態を読む → ②貸出可なら更新する」を別々の命令で行っていたこと。2つの処理がほぼ同時に来ると、両方が①で「貸出可」を読んでしまいます(典型的な競合状態(レースコンディション))。修正方針は、「貸出可のときだけ貸出中にする」を1つのUPDATE文で行い、更新件数が0なら失敗とすること、そして履歴の追加と同じトランザクションにまとめることでした(修正後のコードは後述の loans.ts)。

バグ2は、一覧ページがキャッシュされていたことが原因で、Server Actionの最後に revalidatePath("/") を入れ、一覧ページを常に最新のDBから描画する設定(dynamic = "force-dynamic")にして解消しました。

「直りました」を鵜呑みにしない 修正後は、自分でもう一度再現手順を試すこと。そして「このバグが二度と起きないことを確かめるテストを書いて」と頼むのが次のSTEPです。AIは直したつもりで別の箇所を壊すことがあります。

STEP6 テストを追加して再発を防ぐ

プロンプト⑤:テストを書かせるClaude Code
src/lib/loans.ts の貸出・返却ロジックに Vitest でテストを追加してください。
- 本番の data/app.db には触らず、テストごとにメモリ上のDBを作る
- 「二重貸出できない」(バグ1)のテストを必ず入れる
- 返却後に再度貸し出せること、名前が空白だけなら入力エラーになることも
npx vitest run が通るまで直して、結果を報告してください。

ここで効いてくるのが、STEP4で貸出ロジックを画面から切り離して src/lib/loans.ts に置いたことです。DB接続を引数で受け取る形にしてあるので、テストでは空のメモリDBを渡せます。

src/lib/loans.test.tsTypeScript
// src/lib/loans.test.ts:貸出ロジックのテスト(Vitest)
import { beforeEach, describe, expect, it } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "@/db/schema";
import { items } from "@/db/schema";
import { lend, giveBack, LendInput } from "./loans";

function createTestDb() {
  // テストごとにメモリ上の空DBを作る(本番のdata/app.dbには触らない)
  const db = drizzle(new Database(":memory:"), { schema });
  migrate(db, { migrationsFolder: "drizzle" });
  return db;
}

describe("貸出・返却", () => {
  let db: ReturnType<typeof createTestDb>;
  let itemId: number;

  beforeEach(() => {
    db = createTestDb();
    const row = db.insert(items).values({ name: "プロジェクター A", category: "プロジェクター" }).returning().get();
    itemId = row.id;
  });

  it("貸出可能な備品は貸し出せる", () => {
    expect(lend(db, { itemId, borrower: "山田" })).toEqual({ ok: true });
  });

  it("貸出中の備品は二重に貸し出せない(バグ報告#1の再発防止)", () => {
    lend(db, { itemId, borrower: "山田" });
    const second = lend(db, { itemId, borrower: "佐藤" });
    expect(second.ok).toBe(false);
  });

  it("返却すると再び貸し出せる", () => {
    lend(db, { itemId, borrower: "山田" });
    expect(giveBack(db, itemId)).toEqual({ ok: true });
    expect(lend(db, { itemId, borrower: "佐藤" }).ok).toBe(true);
  });

  it("名前が空白だけなら入力エラー", () => {
    const parsed = LendInput.safeParse({ itemId: "1", borrower: "   " });
    expect(parsed.success).toBe(false);
  });
});

npx vitest run を実行すると4件すべて成功。試しに修正前のコード(読んでから更新する方式)に戻すと「二重貸出できない」のテストが失敗することも確認しておくと、テストがちゃんとバグを捕まえられると分かり安心です。@/ のようなパスの別名をVitestで解決するため、Claude Codeは vitest.config.ts に vite-tsconfig-paths の設定も追加しました。

助手アイ
助手アイテストって、エンジニアが後から書く面倒なものだと思っていました。AIに書いてもらえるなら、むしろ頼まない理由がないですね

慣れてきたら順番を逆にして、「先にテストを書いて、失敗するのを確認してから実装して」(テスト駆動開発=TDD)と頼むと、AIの実装が仕様からズレにくくなります。

生成された主要コードの解説

ここからは、最終的にできあがった主要なコードを抜粋して解説します(Next.js 15以降・React 19の書き方。ライブラリのバージョンによって細部が変わることがあるので、エラーが出たらそのままAIに貼って直してもらいましょう)。

① テーブル定義(Drizzle ORM)

src/db/schema.tsTypeScript
// src/db/schema.ts:テーブル定義(ここを変えたらマイグレーションを作り直す)
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

// 備品マスタ
export const items = sqliteTable("items", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),                    // 例: ノートPC 14インチ #3
  category: text("category").notNull(),            // 例: PC / プロジェクター / カメラ
  location: text("location").notNull().default(""), // 保管場所
  status: text("status", { enum: ["available", "lent"] })
    .notNull()
    .default("available"),
  createdAt: integer("created_at", { mode: "timestamp" })
    .notNull()
    .$defaultFn(() => new Date()),
});

// 貸出履歴(返却済みも残す=あとから「誰がいつ借りたか」を追える)
export const loans = sqliteTable("loans", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  itemId: integer("item_id")
    .notNull()
    .references(() => items.id),
  borrower: text("borrower").notNull(),
  lentAt: integer("lent_at", { mode: "timestamp" }).notNull(),
  dueAt: integer("due_at", { mode: "timestamp" }),
  returnedAt: integer("returned_at", { mode: "timestamp" }), // null=貸出中
});

SQLiteには日付専用の型がないため、mode: "timestamp" で「DBには数値で保存、TypeScript側ではDateとして扱う」と指定しています。returnedAt が空(null)なら貸出中、という設計で、返却済みの行を消さずに残すので、そのまま貸出履歴になります。

src/db/index.tsTypeScript
// src/db/index.ts:DB接続(サーバー側でだけ読み込む)
import "server-only"; // クライアント側から読み込まれたらビルドエラーにする
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";

const sqlite = new Database(process.env.DATABASE_PATH ?? "data/app.db");
sqlite.pragma("journal_mode = WAL");   // 読み書きが同時に来ても詰まりにくくする
sqlite.pragma("foreign_keys = ON");    // 存在しない備品IDへの貸出を防ぐ

export const db = drizzle(sqlite, { schema });
export type DB = typeof db;

import "server-only" は、このファイルが誤ってブラウザ側のコードから読み込まれたときにビルドエラーにする保険です。WALモードはSQLiteの書き込み方式の1つで、読み込みと書き込みが同時に来ても待たされにくくなります。

② 貸出・返却ロジック(バグ1修正後)

src/lib/loans.tsTypeScript
// src/lib/loans.ts:貸出・返却の本体(画面から切り離してテストできるようにする)
import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import type { DB } from "@/db";
import { items, loans } from "@/db/schema";

export const LendInput = z.object({
  itemId: z.coerce.number().int().positive(),
  borrower: z.string().trim().min(1, "借りる人の名前を入力してください").max(50, "名前は50文字以内で入力してください"),
  dueAt: z.coerce.date().optional(),
});
export type LendInput = z.infer<typeof LendInput>;

export type Result = { ok: true } | { ok: false; message: string };

export function lend(db: DB, input: LendInput): Result {
  return db.transaction((tx) => {
    // 「貸出可能なときだけ貸出中にする」を1文で行う=二重貸出を防ぐ
    const updated = tx
      .update(items)
      .set({ status: "lent" })
      .where(and(eq(items.id, input.itemId), eq(items.status, "available")))
      .run();
    if (updated.changes === 0) {
      return { ok: false, message: "この備品はすでに貸出中か、存在しません" } as const;
    }
    tx.insert(loans)
      .values({ itemId: input.itemId, borrower: input.borrower, lentAt: new Date(), dueAt: input.dueAt })
      .run();
    return { ok: true } as const;
  });
}

export function giveBack(db: DB, itemId: number): Result {
  return db.transaction((tx) => {
    const closed = tx
      .update(loans)
      .set({ returnedAt: new Date() })
      .where(and(eq(loans.itemId, itemId), isNull(loans.returnedAt)))
      .run();
    if (closed.changes === 0) {
      return { ok: false, message: "貸出中の記録が見つかりません" } as const;
    }
    tx.update(items).set({ status: "available" }).where(eq(items.id, itemId)).run();
    return { ok: true } as const;
  });
}

核心は .where(and(eq(items.id, ...), eq(items.status, "available"))) の部分です。「IDが一致し、かつ貸出可のときだけ更新」を1文で行うので、同時に2つの貸出が来ても、成功するのは片方だけになります。db.transaction で履歴の追加とまとめているため、「備品は貸出中になったのに履歴がない」といった中途半端な状態も起きません(better-sqlite3は同期的に動くため、トランザクションも同期関数で書きます)。

③ Server Action(フォームの受け口)

src/app/actions.tsTypeScript
// src/app/actions.ts:フォームから呼ばれるServer Action
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { LendInput, lend, giveBack } from "@/lib/loans";

export type FormState = { ok: boolean; message: string };

export async function lendAction(_prev: FormState, formData: FormData): Promise<FormState> {
  // ① 入力チェック(ブラウザ側のrequiredは簡単に回避できるので、必ずサーバーでも確認)
  const parsed = LendInput.safeParse({
    itemId: formData.get("itemId"),
    borrower: formData.get("borrower"),
    dueAt: formData.get("dueAt") || undefined,
  });
  if (!parsed.success) {
    return { ok: false, message: parsed.error.issues[0]?.message ?? "入力内容を確認してください" };
  }

  // ② DB更新(ロジックは lib/loans.ts に集約)
  const result = lend(db, parsed.data);
  if (!result.ok) return { ok: false, message: result.message };

  // ③ 一覧ページのキャッシュを捨てて最新状態を表示
  revalidatePath("/");
  return { ok: true, message: `${parsed.data.borrower} さんに貸し出しました` };
}

export async function returnAction(formData: FormData): Promise<void> {
  const itemId = Number(formData.get("itemId"));
  if (!Number.isInteger(itemId) || itemId <= 0) return;
  giveBack(db, itemId);
  revalidatePath("/");
}

ファイル先頭の "use server" により、この関数は必ずサーバー側で実行されます。ブラウザから見えるのは「この関数を呼ぶ」ための仕組みだけで、DBのパスやSQLは送られません。フォームの required や maxLength はブラウザの開発者ツールで簡単に外せるので、同じチェックをサーバー側でzodでもう一度行うのが鉄則です。

④ フォーム(Client Component)と一覧(Server Component)

src/app/LendForm.tsxTSX
// src/app/LendForm.tsx:貸出フォーム(ブラウザ側で動くClient Component)
"use client";

import { useActionState } from "react";
import { lendAction, type FormState } from "./actions";

const initialState: FormState = { ok: true, message: "" };

export function LendForm({ itemId }: { itemId: number }) {
  const [state, formAction, pending] = useActionState(lendAction, initialState);

  return (
    <form action={formAction} className="lend-form">
      <input type="hidden" name="itemId" value={itemId} />
      <input name="borrower" placeholder="借りる人" required maxLength={50} />
      <input type="date" name="dueAt" aria-label="返却予定日" />
      <button type="submit" disabled={pending}>
        {pending ? "処理中…" : "貸し出す"}
      </button>
      {state.message && (
        <p role="status" className={state.ok ? "msg-ok" : "msg-error"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

useActionState(React 19)は、Server Actionの戻り値(成功・エラーメッセージ)と、処理中かどうか(pending)を受け取れるフックです。処理中はボタンを押せなくすることで、ダブルクリックによる二重送信もブラウザ側で減らせます(最終的な防御はSTEP5のサーバー側の条件付き更新です)。

src/app/page.tsxTSX
// src/app/page.tsx:一覧+検索(サーバー側で描画されるServer Component)
import { desc, like, or } from "drizzle-orm";
import { db } from "@/db";
import { items } from "@/db/schema";
import { LendForm } from "./LendForm";
import { returnAction } from "./actions";

export const dynamic = "force-dynamic"; // 毎回DBの最新状態を表示する

export default async function Home({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q = "" } = await searchParams;
  const keyword = q.trim().slice(0, 50);

  const rows = await db
    .select()
    .from(items)
    .where(
      keyword
        ? or(like(items.name, `%${keyword}%`), like(items.category, `%${keyword}%`))
        : undefined,
    )
    .orderBy(desc(items.createdAt));

  return (
    <main>
      <h1>備品貸出管理</h1>
      <form>
        <input name="q" defaultValue={keyword} placeholder="備品名・カテゴリで検索" />
        <button type="submit">検索</button>
      </form>
      <ul className="item-list">
        {rows.map((item) => (
          <li key={item.id}>
            <strong>{item.name}</strong>({item.category})
            {item.status === "available" ? (
              <LendForm itemId={item.id} />
            ) : (
              <form action={returnAction}>
                <input type="hidden" name="itemId" value={item.id} />
                <button type="submit">返却する</button>
              </form>
            )}
          </li>
        ))}
      </ul>
    </main>
  );
}

検索キーワードは like() に渡していますが、Drizzleは値をプレースホルダー(パラメータ)としてSQLと分けて渡すため、キーワードに記号を入れられてもSQLインジェクションにはなりません。AIが「文字列を組み立ててSQLを書く」コードを出してきたら要注意です。また、Reactは {item.name} の中身を自動でエスケープして表示するため、名前欄にHTMLを入れられても実行されません(dangerouslySetInnerHTML を使うコードが出てきたら理由を確認しましょう)。

なお、Next.js 15以降では、ページが受け取る searchParams は await して使う形(Promise)になっています。古い記事のコードをAIが真似するとここで型エラーになることがあります。

⑤ マイグレーション設定

drizzle.config.tsTypeScript
// drizzle.config.ts:マイグレーション(DBの設計変更の履歴)の設定
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",
  dbCredentials: { url: process.env.DATABASE_PATH ?? "data/app.db" },
});

テーブル定義を変えたら npx drizzle-kit generate で「変更内容のSQL」をファイルとして生成し、npx drizzle-kit migrate で適用します。DBの変更履歴がGitに残るので、「いつ列を追加したか」が追えます(開発中の手早い反映用に drizzle-kit push もありますが、社内で運用が始まったら履歴が残る generate→migrate を推奨します)。

社内で使う前に決めること(入力チェック・ログイン・バックアップ・公開)

「localhostで動いた」はゴールではありません。社内の人が毎日使い、データが溜まっていくアプリにするには、次の4つを決めておきます。

1. 入力チェック:サーバー側で・具体的なメッセージで

  • サーバー側で必ず検証(zodのスキーマ)。ブラウザ側のチェックは「使いやすさ」のため、サーバー側は「守り」のため
  • 文字数の上限を決める(名前50文字など)。上限がないと巨大な文字列でDBが膨らむ
  • エラーメッセージは「何をどう直せばいいか」が分かる言葉に(「入力エラー」ではなく「借りる人の名前を入力してください」)
  • AIへの頼み方の例:「すべてのServer Actionで、zodによる入力検証が行われているか一覧で確認して。漏れがあれば追加して」

2. ログイン:社内利用なら「どこまで守るか」で選ぶ

方式向いているケース注意点
ログインなし+社内LAN限定社内ネットワークからしか届かない場所で動かし、全員が同じ権限でよい「誰が操作したか」は自己申告になる。社外から届く場所には絶対に置かない
簡易認証(共通パスワード/Basic認証)小さなチームで、最低限の鍵をかけたいパスワードは環境変数(.env.local)に置き、Gitに入れない。必ずHTTPSで。退職者が出たら変更する運用が必要
社内アカウントでログイン(Auth.js等でGoogle Workspace/Microsoft 365連携)操作者を確実に記録したい、総務だけに登録・削除を許可したい設定がやや複雑。OAuthのクライアント設定は情シスと相談。権限チェックはServer Actionの中でも行う

今回の仕様(社内LAN内・約40人)では、まずLAN限定で運用を始め、総務専用の登録・削除画面にだけ簡易認証をかけるという段階的な形にしました。AIに頼むときは「ログインの仕組みを作って」ではなく、「誰が・何をできるか」の表を渡して「この権限表どおりにして。画面を隠すだけでなくServer Action側でも権限を確認して」と伝えるのがコツです。画面のボタンを隠しても、Server Actionは直接呼べてしまうからです。

3. データのバックアップ:SQLiteは「1ファイル」だからこそ

SQLiteはDB全体が data/app.db という1ファイルなので、扱いは簡単ですが、そのファイルが壊れたり消えたりすると全データを失います。アプリ稼働中にファイルを単純コピーすると、書き込み途中の不完全なコピーになることがあるため、SQLiteのバックアップ機能を使うスクリプトをAIに作らせます。

scripts/backup.mjsJavaScript
// scripts/backup.mjs:SQLiteを安全にコピーする(アプリ稼働中でもOK)
import Database from "better-sqlite3";
import { mkdirSync, readdirSync, rmSync } from "node:fs";

const src = process.env.DATABASE_PATH ?? "data/app.db";
const dir = "backup";
mkdirSync(dir, { recursive: true });

const stamp = new Date().toISOString().slice(0, 10); // 2026-09-26
const db = new Database(src, { readonly: true });
await db.backup(`${dir}/app-${stamp}.db`); // ファイルコピーではなくSQLiteのバックアップ機能を使う
db.close();

// 古いバックアップは30世代だけ残す
const files = readdirSync(dir).filter((f) => f.endsWith(".db")).sort();
for (const f of files.slice(0, Math.max(0, files.length - 30))) rmSync(`${dir}/${f}`);
console.log(`backup: ${dir}/app-${stamp}.db`);
毎日深夜3時に実行(Linux/macOSのcron例)crontab
0 3 * * * cd /opt/bihin-kanri && node scripts/backup.mjs >> backup.log 2>&1

バックアップは同じPCの中だけに置かず、社内のファイルサーバーなど別の場所にもコピーしておきましょう(WindowsならタスクスケジューラでOK)。そして一度は「バックアップから戻せるか」を実際に試すこと。戻せないバックアップは、ないのと同じです。また、DBファイルとバックアップはGitに入れないようにします。

.gitignore(追記)text
# .gitignore に追記(DBの中身と秘密情報はGitに入れない)
/data/*.db
/data/*.db-wal
/data/*.db-shm
/backup/
.env*.local

4. どこで動かすか(ホスティング):SQLiteはサーバーレスと相性に注意

置き場所SQLiteとの相性ひとこと
社内の常時起動PC・社内サーバー◎npm run build → npm start(PM2などで常駐)。今回の仕様はこれ。LAN内の他のPCからは http://サーバーのIP:3000 で開く
VPS・永続ディスク付きのコンテナ環境○DBファイルを置く永続ボリュームが必須。HTTPSとログインの設定を忘れずに
Vercel等のサーバーレス環境×(そのままでは不可)実行環境のファイルは一時的で、書き込んだDBファイルが消える・共有されない。SQLiteファイルをそのまま置く構成は向かない

サーバーレスで公開したい場合は、Turso(SQLite互換のクラウドDB)やSupabase(PostgreSQL)などの外部のマネージドDBに切り替える選択肢があります。ただしこれは外部サービスのAPI・接続情報を扱うことになるため、本シリーズ「APIなし開発」の範囲外です。まずは社内の1台で運用し、利用が定着してから検討するのが現実的です。

エージェント博士
エージェント博士「AIがVercelにデプロイしましょうと言ったから置いたら、翌日データが消えていた」——これはSQLiteでよくある事故じゃ。データがどこに・どうやって残るのかを説明できないうちは、公開ボタンを押してはいかんぞ

つまずきポイントとAIへの直し方の頼み方

症状よくある原因AIへの頼み方
npm install で better-sqlite3 のビルドエラーネイティブモジュール(C言語部分を含む部品)で、Node.jsのバージョンと合う配布物がない「エラー全文を貼ります。Node.jsのバージョンが原因か確認し、LTS版に合わせる手順を教えて」
Module not found: Can't resolve 'fs'DB接続のファイルをClient Component("use client")から読み込んでいる「DBアクセスがクライアント側に漏れている箇所を探して、Server Action経由に直して」
登録したのに一覧に出ない・古いページのキャッシュ。revalidatePath の呼び忘れ「データ更新後に一覧が最新になる仕組みを、全Server Actionで確認して」
no such table: itemsマイグレーション未適用、またはDBファイルのパス違い「drizzle.config.ts と src/db/index.ts が同じDBファイルを見ているか確認して」
日付が1日ずれるタイムゾーン(UTCと日本時間)の扱い「返却予定日は日付だけ扱いたい。日本時間で表示・保存がずれないように方針を提案して」
AIが頼んでいない機能まで作る・大きく書き換える指示の範囲があいまいEscで中断 →「docs/spec.md の範囲だけ。変更するファイルを先に列挙して」。ダメなら git で直前のコミットに戻す

会話が長くなってAIの応答がちぐはぐになってきたら、/clear で会話をリセットし、「docs/spec.md と CLAUDE.md を読んでから、次は履歴画面を作って」と仕様メモを起点に再開するのが効果的です。仕様メモとCLAUDE.mdを最初に作った価値は、ここでも効いてきます。

発展アイデア

  • 会議室・社用車の予約:「備品」を「時間枠」に置き換えると予約システムに。重複予約の防止は、今回の二重貸出対策と同じ考え方(条件付き更新・一意制約)で作れる
  • 返却期限切れの一覧:dueAt が過ぎて未返却のものを赤字で表示。総務のトップ画面に
  • CSVエクスポート/インポート:既存のExcel台帳を一括取り込み、棚卸し用に書き出し
  • QRコードラベル:備品に貼ったQRをスマホで読むと、その備品の貸出画面が開く
  • 利用状況の集計:よく借りられる備品=買い増し候補、まったく借りられない備品=処分候補

どれも「仕様メモに1項目追加 → Plan Modeで影響範囲を確認 → 実装 → テスト追加」のサイクルで進められます。テーブル定義が変わる追加(予約など)は、特にPlan Modeでの確認を丁寧に。

事例:AIエージェントと業務ツールを作る現場

自社事例

このサイトの運用ツールもAIエージェントと作った

AIエージェント研究所(約57ページの静的サイト)は、Claude Codeとの共同作業で作成・運用しています。公開前に動かす2つのNode.jsスクリプトも、AIと一緒に作った業務ツールです。

  • build.js:共通フッターを全ページに流し込み、流し込めないページがあるとエラーで止まる(表記漏れ防止)
  • keihyo-check.js:景品表示法まわりの表記を機械チェックし、重大な問題があれば止める

どちらも発想は本記事の「サーバー側の入力チェック」や「二重貸出のテスト」と同じで、人が見落としやすいミスを仕組みで止めるためのものです。作業は常に作業ブランチで行い、人が確認してから取り込む流れにしています。詳しくは【自社事例】AIエージェントでメディアサイトを運用する仕組みで紹介しています。

他社の公開事例:Anthropicは、社内でClaude Codeを広く使っており、Claude Code自体のコードの多くもClaude Codeで書いていると公表しています。また、エンジニアではない職種の人がAIエージェントを使って社内の業務ツールを自作する動きも、さまざまな企業で広がっています。

一方で、2025年2月にAndrej Karpathy氏が広めた「バイブコーディング(vibe coding)」——コードを読まずに雰囲気でAIに任せきる作り方——は、使い捨ての試作には便利でも、社内の人が毎日使い、データが溜まっていくアプリには危うさがあります。本記事の進め方(仕様メモ→計画確認→テスト)は、その対極にある「仕様を伝え、確認しながら任せる」やり方です。

よくある質問

プログラミング未経験でも、この備品管理アプリは作れますか?

AIエージェントに任せればコード自体は書けますが、完全な未経験だとエラーが出たときや構成案を判断するときに迷いやすいです。HTMLとJavaScriptを少し触ったことがある、ターミナルでコマンドを打てる、くらいが目安です。自信がなければ、先に初級の「自分のサイトを作って公開する」から始めると、Claude Codeとのやりとりに慣れられます。

ORMはPrismaとDrizzleのどちらを選べばいいですか?

どちらでも作れます。定義をTypeScriptに一本化し、SQLに近い書き方で軽く作りたいならDrizzle、情報量の多さや管理画面(Prisma Studio)を重視するならPrismaが向いています。大切なのは、AIに候補を比較させて理由とデメリットを説明させ、チームの事情に合わせて人が決めることです。

作ったアプリをVercelなどで公開できますか?

SQLiteのデータベースファイルをそのまま使う構成は、Vercelのようなサーバーレス環境には向きません。実行環境のファイルが一時的なため、保存したデータが消えたり共有されなかったりします。社内の常時起動PCやサーバー、永続ディスク付きのVPSで動かすか、公開が必要ならTursoやSupabaseなど外部のデータベースへの切り替えを検討してください(外部サービスを使うため本記事の範囲外です)。

社内利用ならログイン機能は不要ですか?

社内LANからしか届かない場所で動かし、全員が同じ権限でよければ、ログインなしで始めることもできます。ただし誰が操作したかは自己申告になり、社外から届く場所に置くのは危険です。登録・削除を総務だけに許可したいなら簡易認証や社内アカウント連携を入れ、画面を隠すだけでなくサーバー側の処理でも権限を確認してください。

まとめ|「保存するアプリ」は、作る前と作った後が大事

データを保存するWebアプリは、画面だけのサイトより一段難しく見えますが、AIエージェントと組めば、Next.js+SQLiteで十分に実用的なものが作れます。うまくいくかどうかを分けるのは、コードを書く速さではなく、最初に仕様メモを作ること、Plan Modeで構成と選択理由を確認すること、バグを3点セットで報告してテストで再発を防ぐこと、そして入力チェック・ログイン・バックアップ・置き場所を決めてから使い始めることです。

まずはあなたの職場の「Excelで管理しているけど困っている台帳」を1つ選び、プロンプト①の「まだコードは書かないで、質問して」から始めてみてください。次の記事では、同じAIエージェントとの進め方で、スマホで遊べるブラウザゲーム(PWA)を作ります。

作れるようになったら、スキルを収入につなげる

AIエージェントで「作れる」ことは、それ自体が仕事になります。案件獲得や転職、作業環境づくりに役立つ記事をまとめました。

AI・IT業界への転職ガイド【2026】作ったものをポートフォリオに。未経験からの進め方と転職エージェントの選び方
記事を読む

次の一歩

姉妹サイトの記事で、次に気になりやすいことを調べられます。