saas-product について
SaaS 機能を設計する人向けのプロダクト設計手法です。オンボーディング、空状態、ダッシュボード、通知、サブスクリプション、機能制限を扱い、段階的な複雑さとユーザーの目的を重視します。
主な機能
- 01段階的な複雑さとオンボーディング
- 02空状態とダッシュボードの設計パターン
- 03ローディング、遷移、楽観的 UI
- 04通知チャネルと設定
- 05サブスクリプション UX と機能制限
活用シーン
- 01新規ユーザーの初回利用フローの計画
- 02データなしや読み込み中の画面設計
- 03SaaS 指標ダッシュボードの構築
- 04サブスクリプション更新と機能制限の設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ tgautier/ dotfiles --skill saas-productインストール後、Claude Code で/saas-productを入力して読み込まれたか確認します。
詳細
SaaS プロダクトデザイン
ユーザーの利用定着と継続利用を促進する、本番環境向け SaaS 機能の構築手法です。価値実現までの時間を短縮し、マルチテナント型のサブスクリプションベースアプリケーションの複雑さに対応するパターンに重点を置いています。
デザインシステムのトークンとアクセシビリティについては、/ux-design を参照してください。React コンポーネントのパターンについては、/react を参照してください。機能の背後にある API コントラクトについては、/api-design を参照してください。ドメインモデリングについては、/domain-design を参照してください。
1. デザイン哲学
段階的な複雑さ
まずシンプルに始め、ユーザーの必要に応じて複雑さを明らかにします。新規ユーザーは、60 秒以内に最初の価値を実感できるようにします。高度な機能は段階的に利用可能にします。
| 原則 | 適用方法 |
|---|---|
| 価値実現までの時間 | サインアップから最初の意味のある操作までのステップを最小限にします |
| 段階的な情報開示 | 高度なオプションは「Advanced」トグルやサブメニューの背後に隠します |
| 達成すべきジョブ | データエンティティではなく、ユーザーが達成しようとしていることを中心に設計します |
| 適切なデフォルト | 最も一般的な選択肢を設定に事前入力し、デフォルトの操作経路が正しく機能するようにします |
| 確認よりも取り消し | 操作の流れを中断する確認ダイアログより、元に戻せる操作と取り消しを優先します |
プロダクトの階層
Feature → Page → Section → Component
各レベルには明確な責任があります。
- 機能:一連の機能(例:「資産管理」)
- ページ:機能内のビュー(例:「資産一覧」、「資産詳細」)
- セクション:ページ内の論理的なグループ(例:「パフォーマンスチャート」、「取引履歴」)
- コンポーネント:再利用可能な UI 要素(例:「通貨バッジ」、「日付ピッカー」)
2. オンボーディングと初回利用
アクティベーション指標
オンボーディングを構築する前に、「アクティベーション済み」の定義を決めます。
| 指標 | 例 |
|---|---|
| セットアップ完了 | ユーザーが少なくとも 1 つのデータソースを接続している |
| 初めての価値 | ユーザーが実データを含む最初のダッシュボードを閲覧している |
| 習慣の形成 | ユーザーが最初の 7 日間に 3 回再訪している |
段階的なオンボーディングのパターン
セットアップウィザード — 初期設定が必要なプロダクト向け:
// Loader returns current step from session/DB
export async function loader({ request }: LoaderFunctionArgs) {
const progress = await getOnboardingProgress(request);
return { step: progress.currentStep, steps: progress.steps };
}
// Action validates current step, saves, and advances
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
if (formData.get("_intent") === "skip") return redirect("/app");
const nextStep = await saveStepAndAdvance(formData);
if (nextStep === "complete") return redirect("/app");
return redirect(`/onboarding/step/${nextStep}`);
}
// Component renders the current step as a form
function SetupWizard() {
const { step, steps } = useLoaderData<typeof loader>();
return (
<div>
<StepIndicator steps={steps} current={step} />
<Form method="post">
<CurrentStepFields step={step} />
<Button type="submit">Continue</Button>
</Form>
<Form method="post">
<input type="hidden" name="_intent" value="skip" />
<button type="submit" className="text-sm text-muted-foreground">
Skip setup — I'll do this later
</button>
</Form>
</div>
);
}
ルール:
- 常にスキップできるようにし、すべてのステップの完了を強制しない
- 進捗は自動保存する。各ステップの操作内容は送信され、サーバー側に永続化される
- ステップ数は最大 3~5 にする。それ以上になると、ユーザーは途中で離脱します
- 進捗を明確に表示する(4 ステップ中 2 ステップ目)
チェックリストパターン — セットアップを段階的に進めるプロダクト向け:
function OnboardingChecklist({ tasks }: { tasks: OnboardingTask[] }) {
const completed = tasks.filter(t => t.done).length;
return (
<Card>
<Progress value={completed} max={tasks.length} />
<p>{completed} of {tasks.length} complete</p>
{tasks.map(task => (
<ChecklistItem key={task.id} task={task} />
))}
{completed === tasks.length && <DismissButton />}
</Card>
);
}
コンテキストに応じたツールチップ — 初期セットアップ後の機能発見向け:
- ユーザーごとに 1 回だけ表示し、閉じたことをユーザー設定に記録する
- 一般的な領域ではなく、特定の UI 要素を指し示す
- 明確な CTA を 1 つだけ含める(「今すぐ試す」/「了解」)
アンチパターン
- 強制的な動画ツアー(ユーザーはスキップします)
- すべての要素に同時に表示するツールチップ(ユーザーを圧倒します)
- オンボーディングが完了するまでアプリをブロックする(離脱を招きます)
- すでに完了したリピーターにオンボーディングを表示する
3. 空の状態
データを扱うビューでは、次の 4 つの空の状態に対応する必要があります。
| 種類 | 状況 | 内容 |
|---|---|---|
| 初回利用 | ユーザーがまだデータを作成していない | イラスト+説明+主要 CTA |
| 検索結果なし | 検索またはフィルターの結果がない | 「X の検索結果はありません」+検索範囲を広げる提案 |
| エラー | データの読み込みに失敗した | エラーメッセージ+再試行ボタン |
| フィルター適用後に結果なし | 適用したフィルターですべての結果が除外された | 適用中のフィルター+「フィルターをクリア」ボタンを表示 |
初回利用時の空の状態のパターン
function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="mb-4 text-muted-foreground">{icon}</div>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
{action && (
<Button className="mt-4" asChild>
<Link to={action.href}>{action.label}</Link>
</Button>
)}
</div>
);
}
// Usage — CTA navigates to a route, not an onClick handler
<EmptyState
icon={<WalletIcon size={48} />}
title="No assets yet"
description="Add your first asset to start tracking your portfolio performance."
action={{ href: "/assets/new", label: "Add Asset" }}
/>
ルール:
- 初回利用時の空の状態には、最初の項目の作成につながる CTA を含める
- 何もないページや、単独の「データがありません」というメッセージを表示しない
- イラストやアイコンを使い、空の状態が意図的なものだと伝える。壊れているように見せない
- CTA は明確な操作 1 つに絞り、複数の選択肢を提示しない
4. ダッシュボードのデザイン
KPI カードの構成
適切に設計された KPI カードには、現在の値、トレンド指標、比較期間、任意のスパークラインを表示します。
interface KPICardProps {
label: string;
value: string;
change: number; // percentage change
period: string; // "vs last month"
sparklineData?: number[];
}
function KPICard({ label, value, change, period, sparklineData }: KPICardProps) {
const isPositive = change >= 0;
return (
<Card>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{value}</p>
<div className="flex items-center gap-1 text-sm">
<TrendIcon direction={isPositive ? "up" : "down"} />
<span className={isPositive ? "text-green-600" : "text-red-600"}>
{Math.abs(change)}%
</span>
<span className="text-muted-foreground">{period}</span>
</div>
{sparklineData && <Sparkline data={sparklineData} />}
</Card>
);
}
グラフ選択ガイド
| データの関係性 | グラフの種類 | 使用する場面 |
|---|---|---|
| 全体に対する割合 | ドーナツ(最大5区分) | 予算配分、ポートフォリオ構成 |
| 時間の経過に伴う変化 | 折れ線/エリア | 収益の推移、成長指標 |
| 比較 | 横棒 | カテゴリの比較、ランキング |
| 分布 | ヒストグラム | 値の範囲、頻度 |
| 時間の経過に伴う構成 | 積み上げエリア | 時間の経過に伴うセグメント別収益 |
ルール:
- 区分が5つを超える場合、円グラフは使用しないでください。横棒に切り替えてください
- 3D グラフは使用しないでください
- 折れ線グラフには連続した x 軸(時間、順序)が必要です
- 必ず軸にラベルを付け、単位を含めてください
ダッシュボードのレイアウト
- 上段:最も重要な指標を要約する KPI カードを3~4枚
- 中段:主要なグラフ(全幅または幅の2/3)
- 下段:補助データテーブルまたは詳細ビュー
- レイアウトには CSS Grid を使用します。KPI 行には
grid-cols-1 md:grid-cols-2 lg:grid-cols-4を使用します
データ密度
- パワーユーザー向けの高密度表示(列数の多いテーブル、コンパクトな間隔)
- ライトユーザー向けの要約ビュー(KPI カード、スパークライン、簡略化したグラフ)
- ユーザーがビューを切り替えられるようにするか、設定を記憶してください
5. 読み込みと遷移状態
スケルトン画面
スケルトンの形状を実際のコンテンツのレイアウトに合わせてください。ユーザーはスケルトン画面をスピナーよりも速く感じます。
function AssetListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="ml-auto h-4 w-20" />
</div>
))}
</div>
);
}
読み込み状態の判断基準
| 所要時間 | パターン |
|---|---|
| < 100ms | インジケーターは不要 |
| 100-300ms | 控えめなインラインインジケーター(ボタンのスピナー) |
| 300ms-2s | スケルトン画面 |
| 2-10s | プログレスバー、またはメッセージ付きスケルトン |
| > 10s | バックグラウンドタスクとして実行し、完了時に通知 |
楽観的 UI
失敗することがまれな変更処理では、予想される結果をすぐに表示します。React Router v7 では、保留中の送信データである fetcher.formData から楽観的状態を導出します。保留中の項目はデータリストとは別に描画します。楽観的な項目は一時的な UI 状態であり、データではありません。サーバーは loader の再検証を通じて実際の ID を割り当てます。
function TodoList({ items }: { items: Todo[] }) {
const fetcher = useFetcher();
return (
<>
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
{fetcher.formData && (
<li className="opacity-50">
{String(fetcher.formData.get("name") ?? "")}
</li>
)}
</ul>
<fetcher.Form method="post">
<input type="hidden" name="_intent" value="create" />
<input name="name" required />
<Button type="submit">Add</Button>
</fetcher.Form>
</>
);
}
- 送信の処理中は
fetcher.formDataが null 以外になるため、そこから楽観的な項目を直接導出します - action の完了後、loader が再検証され、
itemsが実データで更新され、fetcher.formDataは null にリセットされます - 失敗時も loader は変更されていないデータで再検証されます。
fetcher.formDataが null になるため、楽観的な項目は消えます useOptimistic、onSubmit、startTransitionは不要です。ライフサイクルは React Router のデータレイヤーが処理します
ストリーミング SSR
高速なデータソースと低速なデータソースが混在するページでは:
- 高速なデータ(ナビゲーション、レイアウト)はシェル内にすぐに描画されます
- 低速なデータ(分析、外部 API)は Suspense 境界を介してストリーミングされます
- 各 Suspense 境界では、読み込み中に専用のスケルトンが表示されます
サーバー優先の読み込み原則
ルート loader を使うと、ページの描画時にはデータが利用可能なため、初期データの読み込みにスピナーは必要ありません。スケルトン画面は次の場合に使用してください:
- ストリーミング SSR(Suspense 境界の背後にある低速なデータソース)
- Fetcher による更新(ナビゲーションを伴わない変更処理の実行中)
- マウントガードが必要なクライアント専用コンポーネント
6. 通知の設計
通知チャネル
| チャネル | 用途 | 緊急度 |
|---|---|---|
| アプリ内トースト | 操作の確認、軽微なエラー | 低 — 5秒後に自動的に閉じます |
| アプリ内ベル | 新しいアクティビティ、ステータスの変更 | 中 — 既読になるまで表示されます |
| メール | トランザクション通知(領収書、招待)、ダイジェスト | 低 — 可能な場合はまとめて送信します |
| ブラウザープッシュ通知 | 時間に敏感なアラートのみ | 高 — 作業を中断します |
トーストのベストプラクティス
// Success toast with undo
showToast("Asset deleted", {
action: { label: "Undo", onClick: undoDelete },
duration: 5000,
});
// Error toast — persists until dismissed
showToast("Failed to save changes. Please try again.", {
variant: "error",
duration: Infinity,
action: { label: "Retry", onClick: retryAction },
});
ルール:
- 成功のトーストは5秒後に自動的に閉じます
- エラーのトーストは自動的に閉じないでください。ユーザーが気付かない可能性があります
- 破壊的な操作には取り消しアクションを用意してください
- 複数のトーストは縦に積み重ね、同時に表示する数は3つまでにしてください
- フォームのバリデーションエラーを示す唯一の手段として、トーストを使用しないでください
通知設定
チャネルとイベントのマトリクスとして実装します。
| イベント | アプリ内 | メール | プッシュ |
|---|---|---|---|
| 新しいチームメンバー | デフォルトでオン | デフォルトでオン | デフォルトでオフ |
| 週次ダイジェスト | 該当なし | デフォルトでオン | 該当なし |
| 支払い失敗 | デフォルトでオン | デフォルトでオン | デフォルトでオン |
| 機能アップデート | デフォルトでオン | デフォルトでオフ | デフォルトでオフ |
ユーザーが各セルを個別に設定できるようにします。
7. 課金とサブスクリプションの UX
プランの比較
function PlanComparison({ plans }: { plans: Plan[] }) {
return (
<div className="grid gap-6 md:grid-cols-3">
{plans.map(plan => (
<PlanCard
key={plan.id}
name={plan.name}
price={plan.price}
period={plan.period}
features={plan.features}
recommended={plan.recommended}
current={plan.current}
/>
))}
</div>
);
}
ルール:
- 推奨プランを視覚的に強調します(枠線、バッジ、「最も人気」など)
- 現在のプランを明確に表示し、ユーザーが自分の状況を把握できるようにします
- プランごとに機能をチェックマークで一覧表示し、利用できる機能と利用できない機能の両方を示します
- 年額料金には、月額換算額と割引率を表示します
アップグレードの案内
状況に応じた案内は、一般的なアップセルバナーよりも効果が3倍高くなります。
// GOOD — contextual, shown when the user hits a limit
function FeatureLimitPrompt({ feature, limit, current }: LimitPromptProps) {
return (
<Alert>
<p>You've used {current} of {limit} {feature}.</p>
<Button variant="link" asChild>
<Link to="/settings/billing">Upgrade for unlimited {feature}</Link>
</Button>
</Alert>
);
}
// BAD — generic banner shown on every page
<Banner>Upgrade to Pro for more features!</Banner>
トライアルとダウングレード
- トライアルの残り日数は、控えめで常に表示されるインジケーターで示します(ポップアップにはしません)
- ダウングレードの前に、機能一覧だけでなく、ユーザーが失うものを示します
- ダウングレード後は、機能を削除せず、読み取り専用にするなど適切に制限します
- ダウングレード時にユーザーデータを削除してはいけません。アクセス不可としてマークし、再アップグレードできるようにします
8. 機能の制限
実装パターン
| パターン | 使用する場面 |
|---|---|
| 機能フラグ | 新機能を段階的にユーザーへ展開する場合(ユーザーの一部に適用) |
| プランによる制限 | 特定のサブスクリプションプランでのみ機能を利用できる場合 |
| ロールによる制限 | 特定のユーザーロール(管理者、メンバー)に機能を制限する場合 |
| 使用量制限 | 機能に請求期間ごとの使用枠がある場合 |
段階的な機能制限
機能が制限されている場合、ユーザーに利用できない機能と利用方法を示します。
// Check access in the loader — never send gated data to the client
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request);
const access = await checkFeatureAccess(user, "advanced-analytics");
if (!access.granted) {
return { gated: true, requiredPlan: access.requiredPlan };
}
const data = await loadAnalytics();
return { gated: false, data };
}
// Component renders based on loader data
function AnalyticsPage() {
const loaderData = useLoaderData<typeof loader>();
if (loaderData.gated) {
return <UpgradeOverlay requiredPlan={loaderData.requiredPlan} />;
}
return <AnalyticsDashboard data={loaderData.data} />;
}
ルール:
- 制限された機能がある場所を空白にせず、プレビューを表示します
- 制限された機能を完全に非表示にしてはいけません。機能を知ってもらうことがアップグレードにつながります
- エラーメッセージではなく、ぼかしたプレビューやロックアイコンを使用します
- ロールで制限された機能には、アップグレードの案内ではなく「管理者にお問い合わせください」というメッセージを表示します
9. 設定と管理者向け UX
設定の構成
| セクション | 内容 |
|---|---|
| アカウント | プロフィール、メールアドレス、パスワード、2FA |
| チーム | メンバー、招待、ロール |
| 課金 | プラン、支払い方法、請求書 |
| 設定 | テーマ、言語、通知設定 |
| インテグレーション | 接続済みサービス、API キー |
| 危険な操作 | アカウントの削除、データのエクスポート |
危険な操作
破壊的な設定は視覚的に区別し、確認を必須にします。
// Action — server-side validation (client-side pattern is bypassable)
export async function action({ request }: ActionFunctionArgs) {
const user = await requireAuth(request);
const formData = await request.formData();
if (formData.get("_intent") === "delete-account") {
const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== "delete my account") {
return Response.json(
{ error: "Confirmation phrase does not match", intent: "delete-account" },
{ status: 400 },
);
}
await api.deleteAccount(user.id);
return redirect("/goodbye");
}
}
// Component
function DangerZone() {
return (
<Card className="border-red-200 bg-red-50">
<h3 className="text-red-900">Danger Zone</h3>
<div className="space-y-4">
<Form method="post">
<input type="hidden" name="_intent" value="delete-account" />
<p className="text-sm">Permanently delete your account and all data. This cannot be undone.</p>
<label htmlFor="delete-confirmation" className="text-sm font-medium">
Type "delete my account" to confirm
</label>
<input
id="delete-confirmation"
name="confirmation"
required
pattern="delete my account"
/>
<Button type="submit" variant="destructive">Delete account</Button>
</Form>
</div>
</Card>
);
}
ルール:
- 危険な操作のセクションには赤い枠線または背景を使用します
- 元に戻せない操作では、確認フレーズの入力を必須にします
- サーバーアクションで確認値を検証します。クライアント側の
patternは UX 上のヒントであり、セキュリティ上の境界ではありません - 削除または失われる内容を明確に説明します
- アカウントを削除する前に、データをエクスポートできるようにします
10. 監査ログとアクティビティフィード
フィードの構造
監査ログの各項目では、誰が、何を、どのリソースに対して、いつ行ったかがわかるようにします。
interface AuditEntry {
id: string;
actor: { id: string; name: string; avatar?: string };
action: string; // "created" | "updated" | "deleted" | "exported"
resource: { type: string; id: string; name: string };
changes?: FieldChange[];
timestamp: string; // ISO 8601
}
interface FieldChange {
field: string;
from: string | null;
to: string | null;
}
表示パターン
- 日付の見出しを付けて、項目を日ごとにまとめます
- 最新のアクティビティを先に表示します
- 時系列フィードでは、ページ番号ではなく「さらに読み込む」を使ってページ分割します
- アクター、アクションの種類、リソースの種類、日付範囲で絞り込みます
- フィールドの変更は差分表示にします:
変更前の値→ 変更後の値
11. マルチテナンシーへの配慮
UI でのテナントコンテキスト
- 現在の組織名またはワークスペース名をサイドバーかヘッダーに常に表示します
- 組織の切り替えメニューは目立つように配置し、いつでもアクセスできるようにします
- 組織を切り替えた後は、切り替え先の組織のダッシュボードにリダイレクトします(存在しない可能性がある同じページには移動しません)
データの分離
- すべての API リクエストにテナントコンテキストを含める必要があります(ヘッダー、パスパラメーター、またはセッション)
- エラーメッセージ内であっても、他のテナントのデータを表示してはいけません
- 検索結果は現在のテナントに限定する必要があります
- 共有可能なリンクでは、URL パスにテナント識別子を含めます:
/org/{org-id}/assets
共有リソース
一部のリソースはテナント間で共有されます(請求管理者、スーパー管理者向けのビューなど)。次のように明確に区別してください。
- テナント単位のビュー:通常のスタイル
- テナント横断のビュー:異なる背景や管理者バッジなど、見た目を明確に区別
12. アンチパターン
| アンチパターン | うまくいかない理由 | より良い方法 |
|---|---|---|
| 初回訪問時にブロッキングモーダルを表示する | ユーザーはすぐに閉じてしまい、内容を見逃します | インラインチェックリストまたは状況に応じたヒント |
| 空の状態を「データなし」とだけ表示する | 壊れているように感じられ、案内がありません | 初回利用向けの空の状態と CTA |
| 読み込みのたびにスピナーを表示する | ユーザーは遅いと感じます | コンテンツの形状に合わせたスケルトンスクリーン |
| 汎用的なアップグレードバナー | バナーブラインドネスが起き、ユーザーに無視されます | 上限に達したときに状況に応じたプロンプトを表示する |
| 設定をフラットなリストにする | 圧倒され、目的の項目を見つけにくくなります | 明確な階層でセクションをグループ化する |
| メニューの中に機能を隠す | 見つけにくくなります | 視覚的な手がかりを使った段階的な開示 |
| すべての操作で確認ダイアログを表示する | ダイアログ疲れを招き、ユーザーは読まずにクリックします | 元に戻せる操作には取り消しパターンを使う |
| メールのみで通知する | ユーザーが見逃し、アプリ内で認識できません | アプリ内通知センターとメールによるフォールバック |
| 無料プランを完全に制限する | 有料プランへの移行のハードルが高くなります | 利用量に応じた上限のある、充実した無料プラン |
| ダウングレード時にデータを即時削除する | ユーザーがプランの変更をためらいます | 猶予期間と読み取り専用アクセス |
| フィルターのないアクティビティフィード | アクティブな組織ではノイズに重要な情報が埋もれます | 実行者、操作、リソース、日付でフィルターする |
| UI にテナントの表示がない | ユーザーが誤った組織を変更してしまいます | 常に現在の組織を表示し、簡単に切り替えられるようにする |
| SPA 時代:クライアント側でウィザードの状態を管理する | 更新すると進行状況が失われ、ディープリンクも戻るボタンも使えません | loader/action を通じてサーバー側でステップを管理する |
SPA 時代:変更操作に onClick ハンドラーを使う | プログレッシブエンハンスメントができず、再検証も行われません | intent パターンを使った <Form method="post"> |
| SPA 時代:クライアント側で機能を制限する | 制限対象のデータがクライアントに送信され、セキュリティリスクになります | loader でアクセスを確認し、制限対象のデータは決して送信しない |
相互参照
/ux-design— デザイントークン、アクセシビリティ、コンポーネント API 設計、フォーム UX/react— React コンポーネントのパターン、フック、状態管理/api-design— REST コントラクト、ページネーション、機能の背後にあるエラー形式/domain-design— 集約境界、エンティティと値オブジェクトの違い、ドメインイベント