3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人情報を扱うWebアプリを「サーバーに一切送らない」で作る(登録不要・運用費ゼロ)

3
Posted at

はじめに

qiita-01-top.png

職務経歴書を作るツールを個人開発しました。氏名・経歴・連絡先といったそこそこセンシティブな個人情報を扱います。
普通ならサーバーとDBを用意して…となりますが、今回はあえて

  • サーバーなし(バックエンドを一切持たない)
  • 会員登録なし(メールアドレスも取らない)
  • 入力データはブラウザから外に出さない

という構成にしました。結果、運用費ゼロかつ「データが漏れようがない」ことがそのままプロダクトの売りになっています。

この記事は、その設計判断と実装の記録です。「個人情報を扱うが、預かりたくはない」系のツールを作る人の参考になれば。

なぜ「サーバーに送らない」を選んだか

職務経歴書ツールは競合が多いですが、そのほとんどが会員登録制です。
個人開発で真っ向勝負しても勝てないので、大手がやりにくいポジションを狙いました。

「登録不要・データはサーバーに送らない」

ユーザーからすると「経歴という繊細な情報を、どこの誰とも知れない個人開発サービスに預けたくない」のは自然な感情です。
その不安をアーキテクチャで丸ごと消す。これは技術的な制約でもあり、同時にマーケティングの軸にもなりました。

全体構成

[ブラウザ] --(静的ファイル配信のみ)--> [Cloudflare Pages]
   |
   +-- 入力データは localStorage に保存(外に出ない)
   +-- PDF はブラウザの印刷機能で生成(サーバー不要)
   +-- バックアップは JSON をローカルにダウンロード
  • フロント: Vite + React + TypeScript
  • 状態管理・永続化: Zustand(persist ミドルウェア)
  • ホスティング: Cloudflare Pages(静的ホスティング)
  • デプロイ: GitHub Actions(push で自動)

サーバーサイドのコードは1行もありません

実装①:入力データは localStorage にだけ置く

qiita-02-wizard.png

状態管理は 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出力もサーバーを使わない

qiita-03-export.png

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/

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?