はじめに
職務経歴書を作るツールを個人開発しました。氏名・経歴・連絡先といったそこそこセンシティブな個人情報を扱います。
普通ならサーバーとDBを用意して…となりますが、今回はあえて
- サーバーなし(バックエンドを一切持たない)
- 会員登録なし(メールアドレスも取らない)
- 入力データはブラウザから外に出さない
という構成にしました。結果、運用費ゼロかつ「データが漏れようがない」ことがそのままプロダクトの売りになっています。
この記事は、その設計判断と実装の記録です。「個人情報を扱うが、預かりたくはない」系のツールを作る人の参考になれば。
なぜ「サーバーに送らない」を選んだか
職務経歴書ツールは競合が多いですが、そのほとんどが会員登録制です。
個人開発で真っ向勝負しても勝てないので、大手がやりにくいポジションを狙いました。
「登録不要・データはサーバーに送らない」
ユーザーからすると「経歴という繊細な情報を、どこの誰とも知れない個人開発サービスに預けたくない」のは自然な感情です。
その不安をアーキテクチャで丸ごと消す。これは技術的な制約でもあり、同時にマーケティングの軸にもなりました。
全体構成
[ブラウザ] --(静的ファイル配信のみ)--> [Cloudflare Pages]
|
+-- 入力データは localStorage に保存(外に出ない)
+-- PDF はブラウザの印刷機能で生成(サーバー不要)
+-- バックアップは JSON をローカルにダウンロード
- フロント: Vite + React + TypeScript
- 状態管理・永続化: Zustand(
persistミドルウェア) - ホスティング: Cloudflare Pages(静的ホスティング)
- デプロイ: GitHub Actions(push で自動)
サーバーサイドのコードは1行もありません。
実装①:入力データは localStorage にだけ置く
状態管理は Zustand。persist ミドルウェアを噛ませるだけで、
ストアの中身が自動で localStorage に同期されます。ネットワークは一切発生しません。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useResumeStore = create<ResumeState>()(
persist(
(set) => ({
resume: createEmptyResume(),
setResume: (resume) => set({ resume }),
patchResume: (patch) => set((s) => ({ resume: { ...s.resume, ...patch } })),
// …
}),
{
name: 'career-sheet-resume-v1', // localStorage のキー
version: 1, // スキーマ変更時のマイグレーション用
},
),
)
これだけで「入力途中で離脱 → 再訪しても続きから書ける」が実現します。
サーバーセッションもcookieも不要。リロードやタブを閉じても消えないのは localStorage の利点です。
実装②:PDF出力もサーバーを使わない
PDF生成といえば Puppeteer をサーバーで動かす…が定番ですが、それだと入力データをサーバーに送ることになり本末転倒。
そこで ブラウザの印刷機能(window.print() → PDFに保存) をそのまま使います。
const handlePdf = () => window.print()
@media print で印刷対象だけ残し、A4レイアウトはCSSで作り込みます。
「プレビューとPDFのページを完全一致させる」ための改ページ制御はそれ自体が大きなテーマなので、
別記事(Zenn)に切り出しました。ポイントはPDF化もクライアント内で完結することです。
実装③:バックアップは JSON のダウンロード/復元
「サーバーに保存しない」の弱点は、別の端末やブラウザにデータを持ち運べないこと。
そこは JSON エクスポート/インポートで補います。
// エクスポート:状態を JSON にして Blob でダウンロード
function downloadJson(filename: string, data: unknown) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
// インポート:ファイルを読んでストアに流し込む
importJson: (json) => {
try {
set({ resume: JSON.parse(json) })
return { ok: true }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
「保管場所はあなたの手元(ファイル)」という形なので、プライバシーを守ったまま可搬性を確保できます。
実装④:SPAのルーティングと静的ホスティング
React Router のSPAを静的ホスティングに載せると、/preview などを直接開いたとき404になります。
Cloudflare Pages では public/_redirects の1行で解決します。
/* /index.html 200
デプロイは GitHub Actions。push すればビルドして wrangler pages deploy で反映されます。
- run: npm ci
- run: npm run build
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=xxxxx
この構成のメリット・デメリット
メリット
- 運用費ゼロ(DBもサーバーもない)
- 情報漏洩のリスクが原理的にない → それが差別化コピーになる
- スケーリングを考えなくていい(ただの静的配信)
- 個人情報を「預からない」ので、規約・セキュリティの責任範囲が激減
デメリットと割り切り
- 端末間同期は自動ではできない → JSON持ち運びで割り切る
- サーバー側でしかできない機能(重い処理、共有リンク等)は持てない
- localStorage はブラウザ/ドメイン単位 → クリアされると消える(バックアップで担保)
おわりに
「個人情報を扱う = サーバーとDBが要る」と反射的に考えがちですが、
扱うけれど預からないという選択肢は、個人開発ととても相性が良いです。
運用コストが消え、セキュリティの心配が減り、しかもそれがユーザーへの安心という価値になる。
同じように「センシティブな情報を扱うが自分で保管したくない」ツールを考えている人は、
一度クライアント完結の構成を検討してみてください。
作ったツール(登録不要・データはブラウザ内だけ): https://keireki.dev/


