Documentation Next.js

はじめに

この記事では、Next.jsを使用してプロダクションレディなSaaSダッシュボードを構築する方法を解説します。SaaS(Software as a Service)ビジネスにおいて、ダッシュボードはユーザーとサービスをつなぐ重要なインターフェースです。

本記事で学べること:

  • Next.jsプロジェクトのセットアップと基本構成
  • TailwindCSSとTremorを使った美しいUI構築
  • Supabaseによるデータ管理とリアルタイム更新
  • NextAuth.jsとStripeを使った認証・決済システム

想定読者:

  • Next.jsの基礎を理解している開発者
  • SaaSアプリケーションの開発に興味がある方
  • ダッシュボードUIの効率的な構築方法を学びたい方

SaaSダッシュボードとは

SaaSダッシュボードは、SaaSアプリケーションの中心的な機能の1つです。ユーザーがアクセスするデータ管理や統計情報、設定などを提供するインターフェースで、Next.jsのような最新のフレームワークを使うことで、パフォーマンスに優れた美しいダッシュボードを構築できます。

ダッシュボードに求められる機能

一般的なSaaSダッシュボードには、以下のような機能が求められます:

機能説明実装例
データ可視化グラフやチャートによる統計表示売上推移、ユーザー数の変化
リアルタイム更新データの自動更新通知、アクティビティフィード
ユーザー管理アカウント設定、権限管理プロフィール編集、チームメンバー招待
課金管理サブスクリプション、請求履歴プラン変更、支払い方法の管理
レスポンシブ対応様々なデバイスでの表示モバイル、タブレット対応

Next.jsとSaaS UIの選定理由

Next.jsを選ぶ理由

Next.jsは、フロントエンド開発において、サーバーサイドレンダリング(SSR)とクライアントサイドレンダリング(CSR)を両立できる強力なフレームワークです。

主な利点:

  1. ハイブリッドレンダリング: SSR、SSG、ISRを状況に応じて使い分け可能
  2. 優れたパフォーマンス: 自動的なコード分割と最適化
  3. SEO対応: サーバーサイドレンダリングによるSEO最適化
  4. APIルート: バックエンドAPIをNext.js内に統合可能
  5. TypeScript対応: 型安全な開発が可能
// Next.js 14のApp Routerを使用したページ構成例
// app/dashboard/page.tsx

import { Suspense } from 'react';
import DashboardMetrics from '@/components/DashboardMetrics';
import LoadingSkeleton from '@/components/LoadingSkeleton';

// サーバーコンポーネントとして実行され、SEOに有利
export default async function DashboardPage() {
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-6">ダッシュボード</h1>
      {/* Suspenseを使用してローディング状態を管理 */}
      <Suspense fallback={<LoadingSkeleton />}>
        <DashboardMetrics />
      </Suspense>
    </div>
  );
}

SaaS UIライブラリの選択

SaaS UIは、ダッシュボードに特化したUIコンポーネントを提供するライブラリで、管理画面やデータの可視化に便利です。例えば、ナビゲーションバーやサイドバー、データテーブル、チャート表示など、一般的なダッシュボードに必要な機能が揃っています。

今回使用する主要なライブラリ:

ライブラリ用途特徴
TailwindCSSスタイリングユーティリティファーストのCSSフレームワーク
Tremorデータ可視化React向けのダッシュボードコンポーネント
Saas UIUIコンポーネントSaaS向けに最適化されたコンポーネント

プロジェクトのセットアップ

初期設定

まず、Next.jsのアプリケーションを初期化します。create-next-appコマンドを使って新しいプロジェクトを作成し、必要なパッケージをインストールします。

# Next.js 14プロジェクトの作成(TypeScript、TailwindCSS、App Router有効)
npx create-next-app@latest my-saas-dashboard --typescript --tailwind --app

# プロジェクトディレクトリに移動
cd my-saas-dashboard

# 必要なパッケージをインストール
npm install @tremor/react @supabase/supabase-js
npm install @saas-ui/react @chakra-ui/react @emotion/react @emotion/styled framer-motion
npm install next-auth @stripe/stripe-js

# 開発用パッケージ
npm install -D @types/node

ディレクトリ構成

推奨するディレクトリ構成は以下の通りです:

my-saas-dashboard/
├── app/                      # App Router
│   ├── (auth)/               # 認証関連ページ
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/          # ダッシュボードページ
│   │   ├── layout.tsx        # ダッシュボード共通レイアウト
│   │   ├── page.tsx          # メインダッシュボード
│   │   ├── analytics/        # 分析ページ
│   │   ├── settings/         # 設定ページ
│   │   └── users/            # ユーザー管理
│   ├── api/                  # APIルート
│   │   ├── auth/
│   │   └── stripe/
│   └── layout.tsx            # ルートレイアウト
├── components/               # 共通コンポーネント
│   ├── charts/               # グラフ・チャート
│   ├── layout/               # レイアウトコンポーネント
│   └── ui/                   # UIコンポーネント
├── lib/                      # ユーティリティ
│   ├── supabase.ts           # Supabaseクライアント
│   └── stripe.ts             # Stripeクライアント
└── types/                    # 型定義

TailwindCSSの設定

tailwind.config.tsを以下のように設定し、Tremorのコンポーネントをサポートします:

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  // Tremorのコンポーネントもスキャン対象に含める
  content: [
    './app/**/*.{js,ts,jsx,tsx,mdx}',
    './components/**/*.{js,ts,jsx,tsx,mdx}',
    './node_modules/@tremor/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      // ダッシュボード用のカスタムカラー
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
        // ダークモード用の背景色
        dashboard: {
          bg: '#0f172a',
          card: '#1e293b',
          border: '#334155',
        },
      },
    },
  },
  // ダークモード対応
  darkMode: 'class',
  plugins: [],
};

export default config;

サイドバーとナビゲーションの実装

ダッシュボードでは、サイドバーが重要な役割を果たします。ユーザーのナビゲーションを円滑にするため、Saas UIのSidebarコンポーネントを利用します。

サイドバーコンポーネント

// components/layout/DashboardSidebar.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
  HomeIcon,
  UsersIcon,
  ChartBarIcon,
  CogIcon,
  CreditCardIcon,
} from '@heroicons/react/24/outline';

// ナビゲーション項目の型定義
interface NavItem {
  name: string;
  href: string;
  icon: React.ComponentType<{ className?: string }>;
}

// ナビゲーション項目の定義
const navigation: NavItem[] = [
  { name: 'ダッシュボード', href: '/dashboard', icon: HomeIcon },
  { name: 'ユーザー管理', href: '/dashboard/users', icon: UsersIcon },
  { name: '分析', href: '/dashboard/analytics', icon: ChartBarIcon },
  { name: '課金管理', href: '/dashboard/billing', icon: CreditCardIcon },
  { name: '設定', href: '/dashboard/settings', icon: CogIcon },
];

export default function DashboardSidebar() {
  // 現在のパスを取得してアクティブ状態を判定
  const pathname = usePathname();

  return (
    <aside className="fixed left-0 top-0 z-40 h-screen w-64 bg-dashboard-bg border-r border-dashboard-border">
      {/* ロゴエリア */}
      <div className="flex h-16 items-center justify-center border-b border-dashboard-border">
        <span className="text-xl font-bold text-white">SaaS Dashboard</span>
      </div>

      {/* ナビゲーションリスト */}
      <nav className="mt-6 px-4">
        <ul className="space-y-2">
          {navigation.map((item) => {
            // 現在のページかどうかを判定
            const isActive = pathname === item.href;

            return (
              <li key={item.name}>
                <Link
                  href={item.href}
                  className={`
                    flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium transition-colors
                    ${isActive
                      ? 'bg-primary-600 text-white'
                      : 'text-gray-400 hover:bg-dashboard-card hover:text-white'
                    }
                  `}
                >
                  <item.icon className="h-5 w-5" />
                  {item.name}
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>

      {/* ユーザー情報(サイドバー下部) */}
      <div className="absolute bottom-0 left-0 right-0 border-t border-dashboard-border p-4">
        <div className="flex items-center gap-3">
          <div className="h-10 w-10 rounded-full bg-primary-600" />
          <div>
            <p className="text-sm font-medium text-white">ユーザー名</p>
            <p className="text-xs text-gray-400">user@example.com</p>
          </div>
        </div>
      </div>
    </aside>
  );
}

ダッシュボードレイアウト

// app/(dashboard)/layout.tsx
import DashboardSidebar from '@/components/layout/DashboardSidebar';
import DashboardHeader from '@/components/layout/DashboardHeader';

interface DashboardLayoutProps {
  children: React.ReactNode;
}

export default function DashboardLayout({ children }: DashboardLayoutProps) {
  return (
    <div className="min-h-screen bg-dashboard-bg">
      {/* サイドバー */}
      <DashboardSidebar />

      {/* メインコンテンツエリア */}
      <div className="ml-64">
        {/* ヘッダー */}
        <DashboardHeader />

        {/* ページコンテンツ */}
        <main className="p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

データの表示と管理

ダッシュボードの主な機能として、データの可視化が挙げられます。例えば、売上データやユーザーのアクティビティを表示する場合、Tremorライブラリを使って簡単にチャートを作成できます。

メトリクスカードの実装

// components/charts/MetricsCard.tsx
'use client';

import { Card, Metric, Text, Flex, BadgeDelta } from '@tremor/react';

// メトリクスの型定義
interface MetricsCardProps {
  title: string;           // カードのタイトル
  metric: string;          // メインの数値
  delta: string;           // 前期比の変化率
  deltaType: 'increase' | 'decrease' | 'unchanged'; // 変化の種類
}

export default function MetricsCard({
  title,
  metric,
  delta,
  deltaType,
}: MetricsCardProps) {
  return (
    <Card className="bg-dashboard-card border-dashboard-border">
      <Flex justifyContent="between" alignItems="center">
        <Text className="text-gray-400">{title}</Text>
        {/* 変化率を色分けして表示 */}
        <BadgeDelta deltaType={deltaType} size="sm">
          {delta}
        </BadgeDelta>
      </Flex>
      {/* メインの数値を大きく表示 */}
      <Metric className="mt-2 text-white">{metric}</Metric>
    </Card>
  );
}

売上チャートの実装

// components/charts/SalesChart.tsx
'use client';

import { Card, Title, LineChart } from '@tremor/react';

// 売上データの型定義
interface SalesData {
  date: string;
  sales: number;
  orders: number;
}

// サンプルデータ(実際はAPIから取得)
const salesData: SalesData[] = [
  { date: '2024-01-01', sales: 3000, orders: 45 },
  { date: '2024-01-02', sales: 4500, orders: 62 },
  { date: '2024-01-03', sales: 3800, orders: 51 },
  { date: '2024-01-04', sales: 5200, orders: 78 },
  { date: '2024-01-05', sales: 4100, orders: 55 },
  { date: '2024-01-06', sales: 6300, orders: 89 },
  { date: '2024-01-07', sales: 5800, orders: 82 },
];

// 数値を通貨形式にフォーマット
const formatCurrency = (value: number): string => {
  return new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency: 'JPY',
  }).format(value);
};

export default function SalesChart() {
  return (
    <Card className="bg-dashboard-card border-dashboard-border">
      <Title className="text-white">売上推移</Title>
      <LineChart
        className="mt-4 h-72"
        data={salesData}
        index="date"
        categories={['sales', 'orders']}
        colors={['blue', 'emerald']}
        valueFormatter={formatCurrency}
        yAxisWidth={80}
        // ツールチップのカスタマイズ
        showLegend={true}
        showGridLines={true}
        curveType="monotone"
      />
    </Card>
  );
}

Supabaseとの連携

Supabaseなどのバックエンドサービスを使えば、リアルタイムでデータを取得して更新することも可能です。Supabaseは、PostgreSQLに基づくデータベースを持つBaaS(Backend as a Service)で、認証やデータ管理の機能を簡単に統合できます。

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';

// 環境変数から接続情報を取得
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

// Supabaseクライアントの作成(シングルトンパターン)
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

// データベースの型定義
export interface Database {
  public: {
    Tables: {
      sales: {
        Row: {
          id: number;
          date: string;
          amount: number;
          created_at: string;
        };
        Insert: Omit<Database['public']['Tables']['sales']['Row'], 'id' | 'created_at'>;
      };
      users: {
        Row: {
          id: string;
          email: string;
          name: string;
          plan: 'free' | 'pro' | 'enterprise';
          created_at: string;
        };
      };
    };
  };
}
// components/charts/RealtimeSalesChart.tsx
'use client';

import { useEffect, useState } from 'react';
import { Card, Title, AreaChart } from '@tremor/react';
import { supabase } from '@/lib/supabase';

interface SalesRecord {
  id: number;
  date: string;
  amount: number;
}

export default function RealtimeSalesChart() {
  const [salesData, setSalesData] = useState<SalesRecord[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 初期データの取得
    const fetchInitialData = async () => {
      const { data, error } = await supabase
        .from('sales')
        .select('*')
        .order('date', { ascending: true })
        .limit(30);

      if (error) {
        console.error('データ取得エラー:', error);
        return;
      }

      setSalesData(data || []);
      setIsLoading(false);
    };

    fetchInitialData();

    // リアルタイム更新のサブスクリプション
    const subscription = supabase
      .channel('sales_changes')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'sales',
        },
        (payload) => {
          // 新しいデータをリストに追加
          setSalesData((current) => [...current, payload.new as SalesRecord]);
        }
      )
      .subscribe();

    // クリーンアップ関数
    return () => {
      subscription.unsubscribe();
    };
  }, []);

  if (isLoading) {
    return <div className="animate-pulse h-72 bg-dashboard-card rounded-lg" />;
  }

  return (
    <Card className="bg-dashboard-card border-dashboard-border">
      <Title className="text-white">リアルタイム売上</Title>
      <AreaChart
        className="mt-4 h-72"
        data={salesData}
        index="date"
        categories={['amount']}
        colors={['blue']}
        showLegend={false}
      />
    </Card>
  );
}

認証と支払いシステムの導入

SaaSアプリケーションでは、ユーザーの認証や支払い処理が必要不可欠です。NextAuth.jsでJWTベースの認証を実装し、Stripeを使ったサブスクリプション管理を行います。

NextAuth.js の設定

// app/api/auth/[...nextauth]/route.ts
import NextAuth, { NextAuthOptions } from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import { SupabaseAdapter } from '@auth/supabase-adapter';

// NextAuth.jsの設定オプション
export const authOptions: NextAuthOptions = {
  // 認証プロバイダーの設定
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  // Supabaseをデータベースアダプターとして使用
  adapter: SupabaseAdapter({
    url: process.env.NEXT_PUBLIC_SUPABASE_URL!,
    secret: process.env.SUPABASE_SERVICE_ROLE_KEY!,
  }),
  // セッション設定
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30日間
  },
  // カスタムページの設定
  pages: {
    signIn: '/login',
    error: '/auth/error',
  },
  // コールバック関数
  callbacks: {
    // JWTトークンにユーザー情報を追加
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    // セッションにユーザーIDを追加
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Stripe サブスクリプション管理

// components/billing/SubscriptionManager.tsx
'use client';

import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Card, Title, Text, Button, Badge } from '@tremor/react';

// Stripeインスタンスの初期化
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

// プラン情報の型定義
interface Plan {
  id: string;
  name: string;
  price: number;
  features: string[];
  priceId: string;
}

// 利用可能なプラン
const plans: Plan[] = [
  {
    id: 'free',
    name: 'Free',
    price: 0,
    features: ['基本機能', '月間1,000リクエスト', 'コミュニティサポート'],
    priceId: '',
  },
  {
    id: 'pro',
    name: 'Pro',
    price: 2980,
    features: ['全機能', '月間100,000リクエスト', '優先サポート', 'API アクセス'],
    priceId: 'price_xxxxx',
  },
  {
    id: 'enterprise',
    name: 'Enterprise',
    price: 9800,
    features: ['全機能', '無制限リクエスト', '専任サポート', 'カスタム連携', 'SLA保証'],
    priceId: 'price_yyyyy',
  },
];

interface SubscriptionManagerProps {
  currentPlan: string;
}

export default function SubscriptionManager({ currentPlan }: SubscriptionManagerProps) {
  const [isLoading, setIsLoading] = useState<string | null>(null);

  // チェックアウトセッションの作成
  const handleSubscribe = async (priceId: string, planId: string) => {
    if (!priceId) return; // Freeプランの場合は何もしない

    setIsLoading(planId);

    try {
      // APIエンドポイントを呼び出してチェックアウトセッションを作成
      const response = await fetch('/api/stripe/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ priceId }),
      });

      const { sessionId } = await response.json();

      // Stripeチェックアウトにリダイレクト
      const stripe = await stripePromise;
      await stripe?.redirectToCheckout({ sessionId });
    } catch (error) {
      console.error('チェックアウトエラー:', error);
    } finally {
      setIsLoading(null);
    }
  };

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {plans.map((plan) => (
        <Card
          key={plan.id}
          className={`bg-dashboard-card border-2 ${
            currentPlan === plan.id
              ? 'border-primary-500'
              : 'border-dashboard-border'
          }`}
        >
          <div className="flex items-center justify-between">
            <Title className="text-white">{plan.name}</Title>
            {currentPlan === plan.id && (
              <Badge color="blue">現在のプラン</Badge>
            )}
          </div>

          <div className="mt-4">
            <span className="text-3xl font-bold text-white">
              {plan.price === 0 ? '無料' : `¥${plan.price.toLocaleString()}`}
            </span>
            {plan.price > 0 && (
              <span className="text-gray-400">/月</span>
            )}
          </div>

          <ul className="mt-6 space-y-3">
            {plan.features.map((feature) => (
              <li key={feature} className="flex items-center text-gray-300">
                <svg
                  className="h-5 w-5 text-primary-500 mr-2"
                  fill="currentColor"
                  viewBox="0 0 20 20"
                >
                  <path
                    fillRule="evenodd"
                    d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                    clipRule="evenodd"
                  />
                </svg>
                {feature}
              </li>
            ))}
          </ul>

          <Button
            className="mt-6 w-full"
            onClick={() => handleSubscribe(plan.priceId, plan.id)}
            disabled={currentPlan === plan.id || isLoading === plan.id}
            loading={isLoading === plan.id}
          >
            {currentPlan === plan.id ? '利用中' : 'このプランを選択'}
          </Button>
        </Card>
      ))}
    </div>
  );
}

Stripe Webhook の処理

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { supabase } from '@/lib/supabase';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    // Webhookの署名を検証
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (error) {
    console.error('Webhook署名検証エラー:', error);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // イベントタイプに応じた処理
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;

      // ユーザーのプランを更新
      await supabase
        .from('users')
        .update({ plan: 'pro' })
        .eq('stripe_customer_id', session.customer);

      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;

      // サブスクリプションがキャンセルされた場合、Freeプランに戻す
      await supabase
        .from('users')
        .update({ plan: 'free' })
        .eq('stripe_customer_id', subscription.customer);

      break;
    }

    default:
      console.log(`未処理のイベントタイプ: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

ダッシュボードページの完成形

これまでのコンポーネントを組み合わせて、完成形のダッシュボードページを作成します。

// app/(dashboard)/page.tsx
import { Suspense } from 'react';
import { Grid, Col } from '@tremor/react';
import MetricsCard from '@/components/charts/MetricsCard';
import SalesChart from '@/components/charts/SalesChart';
import RealtimeSalesChart from '@/components/charts/RealtimeSalesChart';
import RecentActivityList from '@/components/dashboard/RecentActivityList';
import LoadingSkeleton from '@/components/ui/LoadingSkeleton';

// メトリクスデータ(実際はAPIから取得)
const metrics = [
  { title: '総売上', metric: '¥1,234,567', delta: '+12.3%', deltaType: 'increase' as const },
  { title: 'アクティブユーザー', metric: '1,234', delta: '+5.2%', deltaType: 'increase' as const },
  { title: '新規登録', metric: '89', delta: '-2.1%', deltaType: 'decrease' as const },
  { title: 'コンバージョン率', metric: '3.2%', delta: '0%', deltaType: 'unchanged' as const },
];

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      {/* ページタイトル */}
      <div>
        <h1 className="text-2xl font-bold text-white">ダッシュボード</h1>
        <p className="text-gray-400">サービスの概要を確認できます</p>
      </div>

      {/* メトリクスカード */}
      <Grid numItemsMd={2} numItemsLg={4} className="gap-6">
        {metrics.map((metric) => (
          <Col key={metric.title}>
            <MetricsCard {...metric} />
          </Col>
        ))}
      </Grid>

      {/* チャートセクション */}
      <Grid numItemsMd={2} className="gap-6">
        <Col>
          <Suspense fallback={<LoadingSkeleton height="h-96" />}>
            <SalesChart />
          </Suspense>
        </Col>
        <Col>
          <Suspense fallback={<LoadingSkeleton height="h-96" />}>
            <RealtimeSalesChart />
          </Suspense>
        </Col>
      </Grid>

      {/* 最近のアクティビティ */}
      <Suspense fallback={<LoadingSkeleton height="h-64" />}>
        <RecentActivityList />
      </Suspense>
    </div>
  );
}

パフォーマンス最適化のポイント

SaaSダッシュボードでは、多くのデータを扱うためパフォーマンス最適化が重要です。

1. サーバーコンポーネントの活用

// データ取得はサーバーコンポーネントで行う
// app/(dashboard)/analytics/page.tsx
import { supabase } from '@/lib/supabase';
import AnalyticsChart from '@/components/charts/AnalyticsChart';

// このコンポーネントはサーバーで実行される
export default async function AnalyticsPage() {
  // サーバー側でデータを取得(クライアントに露出しない)
  const { data: analyticsData } = await supabase
    .from('analytics')
    .select('*')
    .order('date', { ascending: false })
    .limit(30);

  return <AnalyticsChart data={analyticsData || []} />;
}

2. 動的インポートによるコード分割

// components/dashboard/HeavyChart.tsx
'use client';

import dynamic from 'next/dynamic';

// 重いコンポーネントは動的にインポート
const HeavyChartComponent = dynamic(
  () => import('@/components/charts/ComplexVisualization'),
  {
    loading: () => <div className="animate-pulse h-96 bg-dashboard-card rounded-lg" />,
    ssr: false, // クライアントサイドでのみレンダリング
  }
);

export default HeavyChartComponent;

3. データのキャッシュ戦略

// lib/cache.ts
import { unstable_cache } from 'next/cache';
import { supabase } from './supabase';

// データをキャッシュして再利用
export const getCachedMetrics = unstable_cache(
  async () => {
    const { data } = await supabase
      .from('metrics')
      .select('*')
      .single();

    return data;
  },
  ['dashboard-metrics'],
  {
    revalidate: 60, // 60秒ごとに再検証
    tags: ['metrics'],
  }
);

まとめ

Next.jsとSaaS UIを使ったSaaSダッシュボードの構築は、パフォーマンスと開発効率の両面で非常に優れています。リアルタイムのデータ表示やユーザー管理、直感的なUIを簡単に実装できるため、SaaSビジネスの成功に大きく寄与します。

本記事で学んだこと

  1. プロジェクト構成: Next.js 14のApp Routerを使った効率的なディレクトリ構成
  2. UI構築: TailwindCSSとTremorを使ったモダンなダッシュボードUI
  3. データ管理: Supabaseを使ったリアルタイムデータ連携
  4. 認証・課金: NextAuth.jsとStripeによるユーザー管理と課金システム
  5. パフォーマンス: サーバーコンポーネントやキャッシュを活用した最適化

次のステップ

  • テストの追加(Jest、Playwright)
  • エラーハンドリングの強化
  • アクセシビリティ対応
  • 多言語対応(i18n)
  • モバイルアプリ対応(React Native)

参考文献

円