• #個人開発
  • #UX
  • #セキュリティ
  • #設計パターン
開発未分類メモ

個人開発感をなくす10のテクニック

個人開発のプロダクトを使うとき、ユーザーは無意識に「大丈夫かな」と身構える。大手サービスには積み上げてきたブランドがあるけれど、個人開発にはそれがない。マイナスからのスタートだ。

でも、以下の10項目を押さえるだけで「ちゃんとしてる」感は出せる。ブランドではなく、実装で信頼を勝ち取る方法をまとめた。

1. 削除や課金は確認を入れる

後戻りできない操作にワンクッション置くだけで、ユーザーの安心感は格段に上がる。

なぜ重要か

  • 誤タップ・誤クリックによるデータ消失はサービスへの信頼を一発で壊す
  • 課金周りの事故はレビュー炎上に直結する
  • 「取り消せます」という安全ネットがあるだけで、ユーザーは積極的に機能を使える

実装パターン

二段階確認ダイアログ

// ❌ ワンクリックで即削除
<button onClick={() => deleteAccount()}>アカウント削除</button>

// ✅ 確認ステップを挟む
const [showConfirm, setShowConfirm] = useState(false)

{showConfirm ? (
  <div className="confirm-dialog">
    <p>本当にアカウントを削除しますか?この操作は取り消せません。</p>
    <button onClick={() => deleteAccount()}>削除する</button>
    <button onClick={() => setShowConfirm(false)}>キャンセル</button>
  </div>
) : (
  <button onClick={() => setShowConfirm(true)}>アカウント削除</button>
)}

ソフトデリート

データをいきなり消さず、deleted_at カラムで論理削除する。一定期間後にバッチで物理削除すれば、誤操作のリカバリーができる。

-- テーブル設計
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL;

-- 削除(論理削除)
UPDATE users SET deleted_at = NOW() WHERE id = :user_id;

-- 通常のクエリでは除外
SELECT * FROM users WHERE deleted_at IS NULL;

-- 30日後にバッチで物理削除
DELETE FROM users WHERE deleted_at < NOW() - INTERVAL 30 DAY;

課金フローの確認

Stripe Checkoutを使えば、確認画面は自動で提供される。自前で決済UIを組む場合は、金額・プラン名・次回請求日を明示した確認ステップを必ず挟む。

// Stripe Checkout Session - 確認画面が自動で表示される
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/pricing`,
})

2. 漏れたら危険なデータは持たない

守れる根拠がないなら、そもそも保存しない。これが個人開発における最良のセキュリティ戦略だ。

判断基準

データ保存すべきか理由
メールアドレス必要最小限で保存認証・通知に必要。ただしハッシュ化検索も検討
パスワードハッシュのみ保存平文保存は論外。bcrypt/argon2を使う
電話番号保存しないSMS認証はTwilioなど外部サービスに委譲
プロフィール写真外部ストレージS3/R2に保存し、DBにはURLだけ持つ
チャット履歴E2E暗号化か保存しない平文で持つと漏洩時のダメージが大きい
クレジットカード番号絶対に保存しないStripeのトークンだけ保持する
住所・本名保存しない決済サービス側で管理させる

実装の原則

トークン化: 機密データそのものではなく、外部サービスが発行したトークンを保存する。

// ❌ カード情報をDBに保存
await db.insert({ cardNumber: '4242...', expiry: '12/28' })

// ✅ Stripeのカスタマーに紐づけるだけ
const customer = await stripe.customers.create({ email: user.email })
await db.insert({ stripeCustomerId: customer.id })

パスワードのハッシュ化: bcryptかargon2を使う。SHA-256は速すぎてブルートフォースに弱い。

import { hash, verify } from '@node-rs/argon2'

// 保存時
const hashedPassword = await hash(plainPassword)

// 認証時
const isValid = await verify(hashedPassword, inputPassword)

ファイルアップロードの分離: ユーザーがアップロードした画像やファイルはアプリケーションサーバーに置かず、オブジェクトストレージに直接アップロードさせる。

// Presigned URLでクライアントから直接S3/R2にアップロード
const presignedUrl = await s3.getSignedUrl(
  new PutObjectCommand({
    Bucket: 'user-uploads',
    Key: `${userId}/${crypto.randomUUID()}`,
    ContentType: 'image/webp',
  }),
  { expiresIn: 300 }
)

3. 解約を分かりやすくする

解約を隠すサービスは短期的に解約率を下げるが、長期的には「二度と使わない」という感情を植え付ける。

ダークパターンを避ける

  • 解約ボタンを深い階層に埋めない
  • 「本当にいいですか?」を何度も聞かない(1回で十分)
  • 電話やメールでしか解約できない仕組みにしない
  • 解約後もデータがダウンロードできる期間を設ける

実装例

設定画面に解約導線を明示する

<template>
  <section class="danger-zone">
    <h3>サブスクリプション</h3>
    <p>現在のプラン: {{ currentPlan }}</p>
    <p>次回請求日: {{ nextBillingDate }}</p>
    <button @click="cancelSubscription" class="btn-danger">
      プランを解約する
    </button>
    <p class="hint">
      解約後も{{ retentionDays }}日間はデータにアクセスできます。
    </p>
  </section>
</template>

Stripe Subscriptionの解約

// 即時解約ではなく、期間終了時に解約(ユーザーフレンドリー)
await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: true,
})

4. 更新履歴を丁寧に書く

更新履歴は「このサービスはちゃんと運用されている」という最も手軽な証拠になる。

Keep a Changelog形式

keepachangelog.com の形式に従うと、ユーザーにもわかりやすく、統一感のある更新履歴が書ける。

## [1.2.0] - 2026-03-01

### Added
- ダッシュボードにCSVエクスポート機能を追加
- ダークモードに対応

### Changed
- 一覧画面の読み込み速度を改善(平均1.2秒 → 0.4秒)

### Fixed
- iOS Safariでモーダルが閉じない不具合を修正

ポイント

  • ユーザー目線で書く: 「Reactを18.3にアップデート」ではなく「画面の表示速度を改善」
  • 日付を必ず入れる: いつ更新されたかが一目でわかる
  • 壊れた機能を直したら書く: 「○○が動かない」とレビューに書かれる前に「直しました」と伝える
  • セマンティックバージョニング: MAJOR.MINOR.PATCH で変更の規模を伝える

アプリ内に更新履歴を組み込む

// /api/changelog エンドポイントでJSON形式でも提供
export default defineEventHandler(() => {
  return [
    {
      version: '1.2.0',
      date: '2026-03-01',
      changes: [
        { type: 'added', description: 'CSVエクスポート機能' },
        { type: 'fixed', description: 'iOS Safariでのモーダル不具合' },
      ],
    },
  ]
})

5. エラーメッセージに次の行動を書く

「エラーが発生しました」だけでは、ユーザーは何もできない。次に何をすればいいかを伝えるだけで、体験はまったく変わる。

エラーメッセージ設計の3要素

  1. 何が起きたか(状況の説明)
  2. なぜ起きたか(原因がわかる場合)
  3. どうすればいいか(次のアクション)
// ❌ 不親切
throw new Error('エラーが発生しました')

// ❌ 技術者向け
throw new Error('SQLITE_CONSTRAINT: UNIQUE constraint failed: users.email')

// ✅ ユーザー向け
throw createError({
  statusCode: 409,
  message: 'このメールアドレスはすでに登録されています。ログイン画面からサインインしてください。',
})

パターン別のメッセージ例

状況メッセージ
ネットワークエラー通信に失敗しました。接続を確認して、もう一度お試しください。
認証エラーセッションの有効期限が切れました。再度ログインしてください。
入力バリデーションパスワードは8文字以上で、英数字を含めてください。
レート制限リクエストが多すぎます。1分ほど待ってからお試しください。
サーバーエラー処理中にエラーが発生しました。問題が続く場合はサポートまでご連絡ください。

フロントエンドでのError Boundary

// React Error Boundary
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="error-container">
      <h2>予期しないエラーが発生しました</h2>
      <p>お手数ですが、ページを再読み込みしてください。</p>
      <button onClick={resetErrorBoundary}>再読み込み</button>
      <details>
        <summary>技術的な詳細</summary>
        <pre>{error.message}</pre>
      </details>
    </div>
  )
}

6. 画像や動画は圧縮して軽くする

機能が少ないのに表示が遅いと、一気に素人感が出る。画像の最適化はコストパフォーマンスが最も高い改善項目のひとつだ。

画像フォーマットの選択

フォーマット用途サイズ削減率(JPEGと比較)
WebP写真・イラスト全般25-35%削減
AVIF写真(対応ブラウザ向け)40-50%削減
SVGアイコン・ロゴ・図形ベクターなのでスケーラブル

<picture>タグでフォーマットを出し分ける

<picture>
  <!-- AVIFに対応しているブラウザにはAVIF -->
  <source srcset="/images/hero.avif" type="image/avif">
  <!-- WebPに対応しているブラウザにはWebP -->
  <source srcset="/images/hero.webp" type="image/webp">
  <!-- どちらも非対応ならJPEG -->
  <img src="/images/hero.jpg" alt="ヒーロー画像" loading="lazy" decoding="async">
</picture>

ビルド時に自動変換

// sharp を使った変換スクリプト
import sharp from 'sharp'

const convertToWebP = async (inputPath: string) => {
  await sharp(inputPath)
    .webp({ quality: 80 })
    .toFile(inputPath.replace(/\.(jpg|png)$/, '.webp'))
}

レスポンシブ画像

<img
  srcset="
    /images/hero-480w.webp 480w,
    /images/hero-800w.webp 800w,
    /images/hero-1200w.webp 1200w
  "
  sizes="(max-width: 600px) 480px, (max-width: 1000px) 800px, 1200px"
  src="/images/hero-800w.webp"
  alt="ヒーロー画像"
  loading="lazy"
  decoding="async"
>

Lazy Loading

loading="lazy" をつけるだけで、ビューポートに入るまで画像を読み込まない。ファーストビュー以外の画像には必ずつける。ただし、ファーストビュー内の画像(LCP候補)には loading="eager" か属性なしにする。

動画の最適化

  • MP4にはH.264コーデック、WebMにはVP9を使う
  • preload="metadata" でサムネイルだけ先に読み込む
  • 自動再生する背景動画は muted playsinline を必ずつける(特にiOS)
  • 可能ならCloudflare StreamやMux Videoなどの動画CDNに載せる

7. URLの公開IDは連番にしない

/users/1, /users/2, /users/3 のようなURLは、ユーザー数もデータ構造も丸見えになる。

連番IDの問題点

  • 情報漏洩: /invoices/142 を見たユーザーは /invoices/1/invoices/141 が存在すると推測できる
  • スクレイピング: 連番を辿れば全データを収集できる
  • 競合分析: IDの増加ペースから成長速度を推測される

代替手段の比較

方式長さソート可能特徴
UUID v436文字不可広く普及。ただし長い
UUID v736文字可能タイムスタンプベース。DBインデックスに有利
ULID26文字可能UUIDより短い。ソート可能
nanoid21文字(デフォルト)不可短くてURL向き
Sqids(旧Hashids)可変不可内部IDから可逆変換。短い

実装例

nanoid(URL向けの短いID)

import { nanoid } from 'nanoid'

// デフォルト21文字: V1StGXR8_Z5jdHi6B-myT
const id = nanoid()

// カスタム長: 8文字でも衝突確率は十分低い(10万件/年なら数百年)
const shortId = nanoid(12)

UUID v7(DBのプライマリキーに最適)

// Node.js 20+ にはネイティブサポートあり
const id = crypto.randomUUID() // UUID v4

// UUID v7が必要な場合
import { uuidv7 } from 'uuidv7'
const id = uuidv7() // 018e6a3e-3b4f-7000-8000-000000000000

Sqids(内部IDを隠す)

import Sqids from 'sqids'
const sqids = new Sqids({ minLength: 8 })

// 内部ID 142 → 公開ID "86Rf07xd"
const publicId = sqids.encode([142])

// 公開ID → 内部ID に戻せる
const [internalId] = sqids.decode(publicId) // 142

DB設計: 内部のプライマリキーは連番の BIGINT のままでいい。公開URLに使うカラムだけ別に持つ。

CREATE TABLE posts (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  public_id VARCHAR(12) NOT NULL UNIQUE DEFAULT (nanoid(12)),
  title VARCHAR(255) NOT NULL
);

-- URLは /posts/V1StGXR8_Z5j になる

8. 初回体験を最短距離にする

個人開発のプロダクトは知名度がない。ユーザーは「試しに触ってみるか」くらいの気持ちで来る。その短い関心が続いているうちに、価値を届けなければならない。

Time-to-Valueを最短にする設計

サインアップ前に価値を見せる

❌ トップページ → サインアップ → メール認証 → プロフィール設定 → チュートリアル → やっと使える
✅ トップページ → すぐ触れるデモ → 気に入ったらサインアップ

入力項目は後回し

// ❌ 最初から全部聞く
const signupFields = ['name', 'email', 'password', 'company', 'role', 'phone']

// ✅ 最小限で始めて、必要なときに聞く
const signupFields = ['email', 'password']
// company, role, phone は使う機能に初めてアクセスしたときに聞く

プログレッシブ・ディスクロージャー

機能を段階的に見せる。最初からすべてのボタンやメニューを表示すると、ユーザーは「難しそう」と感じて離脱する。

<template>
  <!-- 初回は基本機能だけ表示 -->
  <div v-if="!user.hasUsedAdvanced" class="simple-view">
    <BasicEditor />
    <button @click="showAdvanced = true">もっと細かく設定する</button>
  </div>

  <!-- 使い慣れたら詳細設定を表示 -->
  <div v-else class="advanced-view">
    <AdvancedEditor />
  </div>
</template>

空の状態(Empty State)をデザインする

データがない初期状態こそ、ユーザーが最初に見る画面だ。「データがありません」ではなく、次のアクションを提示する。

<template>
  <div v-if="projects.length === 0" class="empty-state">
    <img src="/illustrations/empty-projects.svg" alt="" />
    <h2>最初のプロジェクトを作りましょう</h2>
    <p>プロジェクトを作ると、タスクの管理や進捗の確認ができます。</p>
    <button @click="createProject">プロジェクトを作成</button>
  </div>
</template>

9. ローディング専用画面を作らない

真っ白な画面にスピナーがぐるぐる回っている──これはユーザーに「待て」と言っているのと同じだ。本当に画面全体を止める必要があるか、考え直してみよう。

スケルトンUI

コンテンツの形を先に表示しておく。ユーザーは「もうすぐ表示される」と感じられる。

<template>
  <div v-if="pending" class="skeleton">
    <div class="skeleton-avatar" />
    <div class="skeleton-text" />
    <div class="skeleton-text short" />
  </div>
  <div v-else>
    <UserProfile :user="data" />
  </div>
</template>

<style scoped>
.skeleton-text {
  height: 1em;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

楽観的更新(Optimistic Update)

サーバーの応答を待たずに、UIを先に更新する。失敗したらロールバックすればいい。

// ❌ サーバーの応答を待ってからUI更新
const handleLike = async () => {
  await api.post(`/posts/${postId}/like`)
  likes.value += 1 // サーバー応答後に反映
}

// ✅ 先にUI更新、失敗したら戻す
const handleLike = async () => {
  likes.value += 1 // 即座に反映
  try {
    await api.post(`/posts/${postId}/like`)
  } catch {
    likes.value -= 1 // 失敗したらロールバック
    toast.error('いいねに失敗しました。もう一度お試しください。')
  }
}

部分ローディング

ページ全体ではなく、更新が必要な部分だけローディングにする。

<template>
  <!-- ヘッダーとサイドバーはすぐ表示 -->
  <AppHeader />
  <Sidebar />

  <main>
    <!-- メインコンテンツだけ非同期読み込み -->
    <Suspense>
      <template #default>
        <DashboardContent />
      </template>
      <template #fallback>
        <DashboardSkeleton />
      </template>
    </Suspense>
  </main>
</template>

ストリーミングSSR(Nuxt 3)

サーバーサイドでHTML全体の生成を待たず、準備できた部分から順にクライアントに送る。

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    componentIslands: true,
  },
})

10. アカウントのデータをすべてダウンロードできるように

「このサービスが終わったら、自分のデータはどうなるの?」──ユーザーのこの不安を解消するのがデータエクスポート機能だ。

なぜ必要か

  • 信頼: 「いつでも出られる」とわかっていると、安心して使える
  • 法令対応: GDPRの「データポータビリティ権」(第20条)は、ユーザーが自分のデータを機械可読な形式で受け取る権利を保障している
  • 差別化: 個人開発でデータエクスポートを実装しているサービスは少ない

実装パターン

JSON + CSVの両方を提供する

// /api/export エンドポイント
export default defineEventHandler(async (event) => {
  const userId = event.context.auth.userId

  // ユーザーに紐づく全データを取得
  const userData = {
    profile: await db.select().from(users).where(eq(users.id, userId)),
    posts: await db.select().from(posts).where(eq(posts.userId, userId)),
    comments: await db.select().from(comments).where(eq(comments.userId, userId)),
    settings: await db.select().from(settings).where(eq(settings.userId, userId)),
  }

  // JSON形式で返す
  setResponseHeader(event, 'Content-Type', 'application/json')
  setResponseHeader(event, 'Content-Disposition', 'attachment; filename="my-data.json"')
  return userData
})

大量データはバックグラウンドで処理する

// データ量が多い場合はジョブキューで非同期処理
export default defineEventHandler(async (event) => {
  const userId = event.context.auth.userId

  // エクスポートジョブを作成
  const job = await db.insert(exportJobs).values({
    userId,
    status: 'processing',
  }).returning()

  // バックグラウンドで処理(完了したらメール通知)
  await queue.add('export-user-data', {
    jobId: job[0].id,
    userId,
  })

  return { message: 'エクスポートを開始しました。完了したらメールでお知らせします。' }
})

エクスポートに含めるべきデータ

  • ユーザーが作成したすべてのコンテンツ(投稿、コメント、ファイル)
  • プロフィール情報
  • 設定・カスタマイズ内容
  • 利用履歴(ログイン日時、操作ログなど)

含めなくていいデータ

  • 他のユーザーのデータ(当然ながら)
  • 内部的なシステムログ
  • 分析用のメタデータ(セッションIDなど)

まとめ

10個のテクニックを振り返る。

#テクニック工数信頼への影響
1確認ダイアログ + ソフトデリート
2データ最小化 + トークン化
3解約導線の明示
4更新履歴の整備
5エラーメッセージの改善
6画像・動画の最適化
7公開IDの設計
8初回体験の最短化
9スケルトンUI + 楽観的更新
10データエクスポート

工数が「小」のものから手をつけるのがいい。確認ダイアログ、エラーメッセージの改善、更新履歴の整備あたりは、1日あれば実装できて効果も高い。

個人開発のプロダクトには、大手のブランド力はない。だからこそ、実装の丁寧さで信頼を積み上げていく。ユーザーは「誰が作ったか」ではなく「どう作られているか」で判断する。