社内名簿シートから選択式スタッフ入力を作る — メールをブラウザに出さない Next.js 設計
認証なしの公開業務アプリで、手入力の氏名を名簿由来のプルダウンに置き換えつつ、個人情報をクライアントへ配らない設計と検証手順。Server Component で {id,name} だけを渡し、サーバが id から氏名とメールを解決する。配信物へのメール混入を grep で実測する検証と、実際に踏んだ落とし穴7件つき。
約4万トークンの節約 (API料金換算で約60円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「社内名簿シートから選択式スタッフ入力を作る — メールをブラウザに出さない Next.js 設計」は、Web開発カテゴリのAI指示書(MDファイル)です。認証なしの公開業務アプリで、手入力の氏名を名簿由来のプルダウンに置き換えつつ、個人情報をクライアントへ配らない設計と検証手順。Server Component で {id,name} だけを渡し、サーバが id から氏名とメールを解決する。配信物へのメール混入を grep で実測する検証と、実際に踏んだ落とし穴7件つき。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約4万トークン(API料金換算で約60円)・89%のトークンを節約できます。
- カテゴリ
- Web開発
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約4.5万トークン
- この巻物使用時
- 約5,000トークン
- 節約量
- 約4万トークン (約60円)
- 更新日
- 2026-09-01
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/next-js/raw を読み込んで、この指示書どおりに実装して"
中身
社内名簿シートから「選択式スタッフ入力」を作る — メールアドレスをブラウザに出さない Next.js 設計
これは何のための指示書か
ログイン機構を持たない業務アプリ(現場のスタッフが URL を開いてすぐ使う類)で、 送信者を手入力させるのをやめて、社内名簿から生成したプルダウンに置き換えるための設計と検証手順。
手入力は表記揺れ(山田花子 / 山田 花子 / やまだ)で集計できなくなるので選択式にしたい。
一方で、名簿にはメールアドレスという個人情報が入っている。認証の無い公開URLのアプリに
名簿をそのまま持ち込むと、ブラウザに全員分のメールアドレスが配られてしまう。
この指示書は「選択式にする」と「メールアドレスをブラウザに出さない」を同時に満たす。
前提と入力
- フレームワーク: Next.js App Router(Server Component が使えること)。同等の SSR フレームワークでも考え方は同じ。
- 名簿の形:
email,role,name,updatedAtのような CSV(スプレッドシートからエクスポートできるもの)。 数千行あり、毎月更新される。 - 抽出条件の例:
roleが特定の値、またはnameが特定の接頭辞(例<接頭辞>)で始まる人だけを対象にする。 接頭辞は表示時に除去する。
絶対に守る設計ルール
- 氏名をコードに書かない。 名簿は毎月変わる。CSV から生成物(
config/staff.json)を作るスクリプトを用意し、 コードには条件だけを書く。ハードコードすると更新のたびに人が編集することになる。 - メールアドレスをクライアントへ渡さない。 Server Component が名簿を読み、
クライアントには
{ id, name }だけを渡す。送信もidのみ。サーバが id から氏名とメールを解決する。 これにより「クライアントの申告を信用しない」も同時に達成できる(なりすまし送信を弾ける)。 - id は安定した非可逆値にする。
sha256(小文字化したメール).slice(0, 12)のように、 同じ人なら再生成しても同じ id になる値を使う。行番号のような並び順依存の値は使わない。 - 生成物に毎回変わる値(生成時刻など)を入れない。 差分が安定し、レビューしやすくなる。
実装
1. 純関数モジュール lib/staff.js
import { createHash } from "node:crypto";
const PREFIX_RE = /^(<接頭辞>)/;
export function parseStaffCsv(csv) {
const lines = String(csv).split(/\r\n|\r|\n/);
let h = 0;
while (h < lines.length && lines[h].trim() === "") h++;
const cols = lines[h].split(",").map((c) => c.trim());
const idx = {};
cols.forEach((n, i) => (idx[n] = i));
for (const req of ["email", "role", "name"]) {
if (!(req in idx)) throw new Error(`CSVヘッダに "${req}" 列がありません`);
}
// 必須列だけで行の有効性を判定する(後述の落とし穴1)
const maxRequired = Math.max(idx.email, idx.role, idx.name);
const rows = [];
for (let i = h + 1; i < lines.length; i++) {
if (lines[i].trim() === "") continue;
const c = lines[i].split(",").map((s) => s.trim());
if (c.length <= maxRequired) continue;
rows.push({ email: c[idx.email], role: c[idx.role], name: c[idx.name] });
}
return rows;
}
export const isTarget = (row) =>
(row.role || "").trim() === "<対象ロール>" || PREFIX_RE.test(row.name || "");
export const toDisplayName = (name) =>
String(name || "").replace(/^(<接頭辞>)[ ]*/, "").trim();
export const staffId = (email) =>
createHash("sha256").update(String(email || "").trim().toLowerCase()).digest("hex").slice(0, 12);
export function buildStaffList(rows) {
const seen = new Set();
const list = [];
for (const r of rows.filter(isTarget)) {
const key = (r.email || "").trim().toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
list.push({
id: staffId(r.email),
name: toDisplayName(r.name),
email: r.email,
role: r.role,
isPrefixed: PREFIX_RE.test(r.name || ""),
});
}
return list.sort((a, b) =>
a.isPrefixed !== b.isPrefixed ? (a.isPrefixed ? -1 : 1) : a.name.localeCompare(b.name, "ja")
);
}
export const findStaffById = (list, id) => (id ? list.find((s) => s.id === id) : undefined);
2. 生成スクリプト scripts/gen-staff.mjs
node scripts/gen-staff.mjs --csv <CSVパス> [--out config/staff.json] [--expect-count N]
- ロジックは
lib/staff.jsを使い、二重に書かない。 - 0件なら exit 1(静かに空リストを出すと、プルダウンが空のまま本番に出る)。
--expect-count不一致なら実際の件数を出して exit 1。- 標準出力に内訳(総行数・条件別件数)を出す。メールアドレスは絶対に出力しない。
3. ページを Server / Client に分ける
// app/page.js(Server Component。"use client" を付けない)
import staff from "../config/staff.json";
import HomeClient from "./home-client";
export default function Home() {
// email はクライアントに渡さない
return <HomeClient staff={staff.map((s) => ({ id: s.id, name: s.name, isPrefixed: s.isPrefixed }))} />;
}
// app/home-client.js("use client")
// <select id="staff-select"> に <optgroup> で2グループ。先頭に「選択してください」を置く。
// 選んだら localStorage に id と表示名を保存する。
// - 旧仕様(手入力の名前)のキーが残っていたら、名簿の name と完全一致すれば id に移行し、旧キーは削除する
// - 保存済み id が名簿に無ければ(退職等)選択なしに戻す
// - staff が0件なら「一覧が未生成です」と出す(真っ白にしない)
4. API 側でサーバが権威を持つ
const staffId = formData.get("staffId");
const staff = findStaffById(STAFF, staffId);
if (!staff) return json({ error: "スタッフを選択してください" }, 400);
// 以降 name は staff.name を使う。クライアントの name は受け取らない
検証(ここまでやって初めて「できた」と言える)
- 単体テスト(
node --test): ヘッダ列順を入れ替えた CSV / CRLF / 空行 / 必須列欠落で throw / 接頭辞除去 / id の安定性と一意性 / 重複排除 / 並び順。フィクスチャのメールはexample.comの架空アドレスにする。 - ビルドして、生成物にメールが混入していないことを grep で実測する(推測で済ませない):
grep -rl -E "@[a-z0-9.-]+\.(com|jp|net)" .next/static/ # 0件が正解
grep -c -E "@" .next/server/app/index.html # 0件が正解
- 実ブラウザでプルダウンを開き、option の件数と表示名を機械的に取り出して期待値と比べる (スクリーンショットの目視だけで終わらせない):
const groups = await page.evaluate(() => {
const sel = document.getElementById("staff-select");
return [...sel.querySelectorAll("optgroup")].map((og) => ({
label: og.label,
names: [...og.querySelectorAll("option")].map((o) => o.textContent),
}));
});
- 本番でも同じ grep を回す(配信されている HTML と、参照されている JS チャンク全部):
for u in $(curl -s "$PROD" | grep -o -E '/_next/static/[^"]+\.js' | sort -u); do
echo "$(curl -s "$PROD$u" | grep -c '@example') $u"
done
- なりすましを弾けるかを実際に叩いて確認する:
staffIdに存在しない値 → 400 / 旧仕様のnameだけ送信 → 400。
落とし穴(実際に踏んだもの)
- 行の有効性判定に「最後の任意列」を含めると、スタッフが静かに消える。
updatedAtのような末尾列を必須扱いにすると、その列が欠けた行が「列数不足」で捨てられ、 プルダウンから人が消える。必須列だけで判定する。 - CSV を API 経由で読むと途中で切り詰められることがある。 「本文をテキストで返す」系のAPIは大きいファイルを黙って truncate する。 件数を数えて期待値と比べ、必要ならエクスポート(CSV ダウンロード)経路に切り替える。
localeCompare("ja")は漢字を読み順に並べない。 ふりがな列が無い名簿では読み順ソートは実現できない。 件数が少なければ割り切るか、名簿側の掲載順を採用する。「五十音順にした」と説明しない。- 公開URLのアプリでは、生成物を private リポに置いてもブラウザ側は別問題。 リポジトリの秘匿性と、配信物に混入していないことは別々に確認する。
- 人が見るチャンネル(チャット等)へ通知する機能を手で叩いて試すときは、投稿を自分で消せる経路だけを使う。
検証スクリプト側で「投稿 → 読み返して検証 → 削除」まで面倒を見る。
Windows で
curl -F "field=日本語"を使うと argv が OS のコードページに変換されて渡り、 サーバには文字化けした文字列が届く。その結果プレースホルダ検知などの送信ガードをすり抜けて 実投稿が成立する。テスト文字列は Node のFormDataで組む。 - サーバレスのリクエスト本文上限(数MB)は、アプリ側の添付上限より小さいことがある。 アプリが 8MB を許しても、プラットフォームが 4.5MB で弾けば親切なエラーは出ずに失敗する。 クライアント側で縮小するのが本来の対策。
- 環境変数を追加・変更したら必ず再デプロイする。 追加前に作られたデプロイには焼かれていない。
完成の定義
- プルダウンに期待した人数だけが出て、接頭辞が除去されている(実ブラウザで機械的に確認済み)
- 単体テスト全pass・ビルド成功・実ブラウザの通しテスト全pass
- 配信物(ローカルビルドと本番の両方)にメールアドレスが1件も含まれないことを grep で確認済み
- 存在しない id と旧仕様の送信が 400 で弾かれることを実際に叩いて確認済み
- 名簿更新の手順が1コマンドに落ちている(人が氏名を編集する箇所が無い)
よくある質問
+「社内名簿シートから選択式スタッフ入力を作る — メールをブラウザに出さない Next.js 設計」とは何ですか?
認証なしの公開業務アプリで、手入力の氏名を名簿由来のプルダウンに置き換えつつ、個人情報をクライアントへ配らない設計と検証手順。Server Component で {id,name} だけを渡し、サーバが id から氏名とメールを解決する。配信物へのメール混入を grep で実測する検証と、実際に踏んだ落とし穴7件つき。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約4.5万トークンかかりますが、この巻物を使えば約5,000トークンで済みます。差し引き約4万トークン(API料金換算で約60円)・89%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
Next.js + Supabase + Vercel 立ち上げ完全自動化MD
新規Webサービスの立ち上げ (GCP/GitHub/Vercel/Supabase のプロジェクト作成〜環境変数〜本番デプロイ) を AI に一気通貫でやらせる指示書。人間の作業はログイン1回だけ。
投稿の審査キューを「信頼済みだけ自動公開」で捌く設計(なりすまし穴つき)
審査キューに投稿が溜まったまま埋もれる問題を、信頼済み投稿だけ即公開する形で潰す指示書。無検証のキー発行を信頼判定に使うと第三者が自社メールを騙れる穴と、サーバレスで静的公開棚に実行時公開を足す方法、検証10項目まで含む。
DB型サイトを「一覧だけ会員限定・個別ページは残す」に切り替える指示書(Next.js App Router)
自社DBで集客していたサイトが競合のリスト抜き取りに気づいた時の改修手順。名前が並ぶバルクな一覧だけを会員限定にし、個別ページはtitle/H1とCTAを残す。ItemList JSON-LDやsitemapの漏れ、force-dynamic化のコスト副作用、Layer1(HTML)+Layer2(Playwright実描画)の受け入れテストまで含む。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア