T-CREATOR

Tailwind CSS と shadcn/ui で本格的な管理画面を開発する流れ

Tailwind CSS と shadcn/ui で本格的な管理画面を開発する流れ

企業向けの管理画面開発において、見た目の美しさと実装効率を両立することは重要な課題です。今回は、Tailwind CSS と shadcn/ui を組み合わせて、プロレベルの管理画面を段階的に構築する方法をご紹介します。

この記事では、環境構築から実際のコンポーネント実装まで、実践的な開発フローを詳しく解説いたします。モダンな技術スタックを活用することで、保守性と拡張性を兼ね備えた管理画面を効率的に開発できるでしょう。

背景

従来の管理画面開発における課題

企業向け管理画面の開発では、これまで多くの課題が存在していました。特に以下の点が開発者を悩ませてきました。

まず、カスタム CSS の管理の複雑さです。プロジェクトが大きくなるにつれて、スタイルシートが肥大化し、クラス名の重複や優先度の問題が頻発します。また、デザイナーとエンジニアの連携においても、デザインシステムの一貫性を保つことが困難でした。

次に、コンポーネントライブラリの選択と統合の難しさがあります。既存のライブラリを使用する場合、カスタマイズの自由度が制限されたり、プロジェクトの要件に完全に合致しないケースが多く発生していました。

さらに、レスポンシブ対応とアクセシビリティへの配慮も大きな負担となっていました。多様なデバイスサイズに対応し、すべてのユーザーが利用しやすいインターフェースを実現するには、膨大な作業量が必要でした。

Tailwind CSS が解決するスタイリング問題

Tailwind CSS は、ユーティリティファーストの設計思想により、これらの課題を根本的に解決します。

mermaidflowchart TD
  traditional[従来のCSS設計] -->|課題| problems[スタイル管理の複雑化]
  problems --> cascade[カスケードの問題]
  problems --> naming[クラス名の重複]
  problems --> maintenance[保守性の低下]
  
  tailwind[Tailwind CSS] -->|解決| utility[ユーティリティクラス]
  utility --> consistency[一貫性の確保]
  utility --> responsive[レスポンシブ対応]
  utility --> performance[パフォーマンス最適化]

従来のCSS設計では、コンポーネントごとにカスタムクラスを作成し、複雑なカスケードルールに悩まされていました。Tailwind CSSでは、小さなユーティリティクラスを組み合わせることで、予測可能で保守しやすいスタイリングが実現できます。

具体的な改善点として、以下が挙げられます。クラス名の命名に悩む必要がなくなり、開発速度が向上します。また、未使用のCSSが自動的に除去されるため、バンドルサイズも最適化されるのです。

shadcn/ui がもたらすコンポーネント開発の効率化

shadcn/ui は、Tailwind CSS をベースとした高品質なコンポーネントライブラリです。従来のライブラリとは異なり、必要なコンポーネントだけを選択してプロジェクトに直接コピーできる特徴があります。

この設計により、完全なカスタマイズの自由度を保ちながら、高品質なコンポーネントを利用できます。また、TypeScript による型安全性も確保されており、大規模なプロジェクトでも安心して使用できるでしょう。

課題

デザインシステムの統一性確保

管理画面開発において、デザインシステムの統一性は極めて重要です。しかし、複数の開発者が関わるプロジェクトでは、以下のような課題が発生しがちです。

色やフォントサイズの不統一が最も顕著な問題です。開発者が独自の判断でスタイルを追加することで、アプリケーション全体の一貫性が損なわれてしまいます。また、コンポーネント間のマージンやパディングの差異も、プロフェッショナルな印象を損ねる要因となります。

さらに、インタラクションパターンの不統一も重要な課題です。ボタンのホバー効果やモーダルの表示方法が画面ごとに異なると、ユーザーエクスペリエンスが著しく低下してしまいます。

開発速度とコードの保守性のバランス

企業向けプロジェクトでは、短期間での開発と長期的な保守性の両立が求められます。

mermaidgraph LR
  speed[開発速度] -->|vs| maintainability[保守性]
  speed --> quick[迅速な実装]
  speed --> deadline[納期対応]
  
  maintainability --> readable[可読性]
  maintainability --> scalable[拡張性]
  maintainability --> testing[テスト容易性]
  
  balance[最適なバランス] --> speed
  balance --> maintainability

この図が示すように、開発速度と保守性は相反する要素として捉えられがちです。しかし、適切な技術選択により、両者を高いレベルで実現することが可能になります。

開発速度を重視しすぎると、技術的負債が蓄積し、後の機能追加や修正に膨大な時間がかかってしまいます。一方で、保守性を重視しすぎると、過度な抽象化により開発が複雑になり、初期の実装速度が大幅に低下してしまうのです。

レスポンシブ対応とアクセシビリティ

現代の管理画面では、デスクトップだけでなく、タブレットやスマートフォンでの利用も考慮する必要があります。

レスポンシブ対応の複雑さは、特にデータテーブルや複雑なフォームにおいて顕著に現れます。画面サイズに応じて表示する情報量を調整し、操作性を維持することは技術的に困難な課題です。

アクセシビリティの確保も同様に重要な要素です。キーボード操作への対応、スクリーンリーダーとの互換性、適切なコントラスト比の確保など、配慮すべき点は多岐にわたります。

解決策

Tailwind CSS + shadcn/ui の組み合わせによる開発手法

Tailwind CSS と shadcn/ui の組み合わせは、前述の課題に対する包括的な解決策を提供します。

この技術スタックの最大の特徴は、設計の一貫性と実装の柔軟性を両立できることです。Tailwind CSS のデザイントークンにより統一感を保ちながら、shadcn/ui のコンポーネントで高品質な UI を迅速に構築できます。

mermaidflowchart TB
  design[デザインシステム] --> tokens[デザイントークン]
  tokens --> colors[カラーパレット]
  tokens --> typography[タイポグラフィ]
  tokens --> spacing[スペーシング]
  
  components[shadcn/ui] --> button[Button]
  components --> table[Table]
  components --> form[Form]
  
  tokens --> components
  components --> app[管理画面アプリ]
  design --> app

デザイントークンが全体の統一性を担保し、コンポーネントが実装効率を向上させる構造になっています。この設計により、大規模なチームでも一貫性のある開発が可能になるのです。

コンポーネント設計とディレクトリ構成

効率的な開発のためには、適切なコンポーネント設計とディレクトリ構成が不可欠です。

Atomic Design の考え方を取り入れ、以下のような階層構造を推奨します。最小単位のコンポーネント(Atoms)から、複雑なページ(Templates)まで段階的に組み立てることで、再利用性と保守性を高められます。

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

bashsrc/
├── components/
│   ├── ui/           # shadcn/ui コンポーネント
│   ├── atoms/        # 基本コンポーネント
│   ├── molecules/    # 複合コンポーネント
│   └── organisms/    # 複雑なコンポーネント
├── pages/           # ページコンポーネント
├── hooks/           # カスタムフック
├── utils/           # ユーティリティ関数
└── styles/          # グローバルスタイル

この構成により、コンポーネントの責務が明確になり、チーム開発においても迷うことなく適切な場所にファイルを配置できます。

テーマとデザイントークンの管理

Tailwind CSS の設定ファイルを活用して、プロジェクト固有のデザイントークンを定義します。

基本的なカラーパレット、フォントサイズ、スペーシングなどを統一することで、ブランドアイデンティティを保ちながら効率的な開発が実現できます。また、ダークモード対応も CSS 変数を活用することで容易に実装できるのです。

具体例

環境構築とセットアップ

まず、Next.js プロジェクトを作成し、必要な依存関係をインストールします。

Next.js プロジェクトの初期化を行います。

typescriptyarn create next-app@latest admin-dashboard --typescript --tailwind --eslint --app
cd admin-dashboard

shadcn/ui の初期設定を実行します。この設定により、プロジェクトに最適化されたコンポーネントライブラリが利用可能になります。

bashyarn add @radix-ui/react-icons
npx shadcn-ui@latest init

設定ファイルでは、TypeScript、Tailwind CSS、CSS variables を選択します。これにより型安全性とスタイリングの柔軟性が確保されます。

Tailwind CSS の設定ファイルをカスタマイズします。

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

const config: Config = {
  darkMode: ["class"],
  content: [
    './pages/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './app/**/*.{ts,tsx}',
    './src/**/*.{ts,tsx}',
  ],
  theme: {
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
}

export default config

この設定により、CSS 変数を活用したテーマシステムが構築されます。ダークモードやカスタムテーマの切り替えも容易に実装できるでしょう。

グローバルスタイルファイルを設定します。

css/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 224 71.4% 4.1%;
    --primary: 220.9 39.3% 11%;
    --primary-foreground: 210 20% 98%;
    --secondary: 220 14.3% 95.9%;
    --secondary-foreground: 220.9 39.3% 11%;
    --border: 220 13% 91%;
    --input: 220 13% 91%;
    --ring: 224 71.4% 4.1%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 224 71.4% 4.1%;
    --foreground: 210 20% 98%;
    --primary: 210 20% 98%;
    --primary-foreground: 220.9 39.3% 11%;
    --secondary: 215 27.9% 16.9%;
    --secondary-foreground: 210 20% 98%;
    --border: 215 27.9% 16.9%;
    --input: 215 27.9% 16.9%;
    --ring: 216 12.2% 83.9%;
  }
}

このスタイル設定により、ライトモードとダークモードの両方に対応した一貫性のあるデザインシステムが構築できます。

基本レイアウトの構築

管理画面の基本となるレイアウトコンポーネントを作成します。

まず、サイドバーナビゲーションコンポーネントを実装します。

typescript// components/layout/sidebar.tsx
'use client'

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  Home,
  Users,
  Settings,
  BarChart3,
  Package,
  FileText
} from "lucide-react"

interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}

export function Sidebar({ className }: SidebarProps) {
  return (
    <div className={cn("pb-12", className)}>
      <div className="space-y-4 py-4">
        <div className="px-3 py-2">
          <h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
            管理画面
          </h2>
          <div className="space-y-1">
            <Button variant="secondary" className="w-full justify-start">
              <Home className="mr-2 h-4 w-4" />
              ダッシュボード
            </Button>
            <Button variant="ghost" className="w-full justify-start">
              <Users className="mr-2 h-4 w-4" />
              ユーザー管理
            </Button>
            <Button variant="ghost" className="w-full justify-start">
              <Package className="mr-2 h-4 w-4" />
              商品管理
            </Button>
            <Button variant="ghost" className="w-full justify-start">
              <BarChart3 className="mr-2 h-4 w-4" />
              分析
            </Button>
            <Button variant="ghost" className="w-full justify-start">
              <FileText className="mr-2 h-4 w-4" />
              レポート
            </Button>
            <Button variant="ghost" className="w-full justify-start">
              <Settings className="mr-2 h-4 w-4" />
              設定
            </Button>
          </div>
        </div>
      </div>
    </div>
  )
}

このサイドバーコンポーネントでは、shadcn/ui の Button コンポーネントと Lucide アイコンを組み合わせて、直感的なナビゲーションを実現しています。

ヘッダーコンポーネントを作成します。

typescript// components/layout/header.tsx
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bell, Search } from "lucide-react"
import { Input } from "@/components/ui/input"

export function Header() {
  return (
    <header className="border-b">
      <div className="flex h-16 items-center px-4">
        <div className="ml-auto flex items-center space-x-4">
          <div className="relative">
            <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
            <Input
              placeholder="検索..."
              className="pl-8 w-[300px]"
            />
          </div>
          <Button variant="ghost" size="icon">
            <Bell className="h-4 w-4" />
          </Button>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" className="relative h-8 w-8 rounded-full">
                <Avatar className="h-8 w-8">
                  <AvatarImage src="/avatars/01.png" alt="@shadcn" />
                  <AvatarFallback>SC</AvatarFallback>
                </Avatar>
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent className="w-56" align="end" forceMount>
              <DropdownMenuLabel className="font-normal">
                <div className="flex flex-col space-y-1">
                  <p className="text-sm font-medium leading-none">田中 太郎</p>
                  <p className="text-xs leading-none text-muted-foreground">
                    tanaka@example.com
                  </p>
                </div>
              </DropdownMenuLabel>
              <DropdownMenuSeparator />
              <DropdownMenuItem>
                プロフィール
              </DropdownMenuItem>
              <DropdownMenuItem>
                設定
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem>
                ログアウト
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </div>
    </header>
  )
}

ヘッダーには検索機能、通知ボタン、ユーザーメニューを配置し、実用的な管理画面の要素を実装しています。

メインレイアウトコンポーネントで全体を統合します。

typescript// components/layout/main-layout.tsx
import { Sidebar } from "./sidebar"
import { Header } from "./header"

interface MainLayoutProps {
  children: React.ReactNode
}

export function MainLayout({ children }: MainLayoutProps) {
  return (
    <div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
      <div className="hidden border-r bg-muted/40 md:block">
        <div className="flex h-full max-h-screen flex-col gap-2">
          <Sidebar />
        </div>
      </div>
      <div className="flex flex-col">
        <Header />
        <main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

この構成により、レスポンシブ対応した管理画面の基本レイアウトが完成します。Grid Layout を使用することで、サイドバーとメインコンテンツが適切に配置されるのです。

データテーブルコンポーネントの実装

管理画面の中核となるデータテーブルを実装します。

まず、shadcn/ui の Table コンポーネントを追加します。

bashnpx shadcn-ui@latest add table
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add button

データテーブルの型定義を作成します。

typescript// types/user.ts
export interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user' | 'moderator'
  status: 'active' | 'inactive' | 'pending'
  createdAt: string
  lastLogin?: string
}

データテーブルコンポーネントを実装します。

typescript// components/data-table/user-table.tsx
'use client'

import { useState } from "react"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { User } from "@/types/user"
import { Search, Filter, MoreHorizontal } from "lucide-react"

interface UserTableProps {
  users: User[]
}

export function UserTable({ users }: UserTableProps) {
  const [searchTerm, setSearchTerm] = useState("")
  const [statusFilter, setStatusFilter] = useState<string>("all")

  const filteredUsers = users.filter((user) => {
    const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
                          user.email.toLowerCase().includes(searchTerm.toLowerCase())
    const matchesStatus = statusFilter === "all" || user.status === statusFilter
    return matchesSearch && matchesStatus
  })

  const getStatusBadge = (status: User['status']) => {
    const variants = {
      active: 'default',
      inactive: 'secondary',
      pending: 'outline'
    } as const

    const labels = {
      active: 'アクティブ',
      inactive: '無効',
      pending: '保留中'
    }

    return (
      <Badge variant={variants[status]}>
        {labels[status]}
      </Badge>
    )
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center space-x-2">
        <div className="relative flex-1">
          <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
          <Input
            placeholder="ユーザーを検索..."
            className="pl-8"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
          />
        </div>
        <Select value={statusFilter} onValueChange={setStatusFilter}>
          <SelectTrigger className="w-[180px]">
            <SelectValue placeholder="ステータス" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="all">すべて</SelectItem>
            <SelectItem value="active">アクティブ</SelectItem>
            <SelectItem value="inactive">無効</SelectItem>
            <SelectItem value="pending">保留中</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="rounded-md border">
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>名前</TableHead>
              <TableHead>メールアドレス</TableHead>
              <TableHead>ロール</TableHead>
              <TableHead>ステータス</TableHead>
              <TableHead>登録日</TableHead>
              <TableHead>最終ログイン</TableHead>
              <TableHead className="text-right">操作</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {filteredUsers.map((user) => (
              <TableRow key={user.id}>
                <TableCell className="font-medium">{user.name}</TableCell>
                <TableCell>{user.email}</TableCell>
                <TableCell className="capitalize">{user.role}</TableCell>
                <TableCell>{getStatusBadge(user.status)}</TableCell>
                <TableCell>{new Date(user.createdAt).toLocaleDateString('ja-JP')}</TableCell>
                <TableCell>
                  {user.lastLogin 
                    ? new Date(user.lastLogin).toLocaleDateString('ja-JP')
                    : '未ログイン'
                  }
                </TableCell>
                <TableCell className="text-right">
                  <Button variant="ghost" size="icon">
                    <MoreHorizontal className="h-4 w-4" />
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>
    </div>
  )
}

このテーブルコンポーネントでは、検索機能、フィルタリング機能、ステータス表示など、実用的な管理画面に必要な機能を実装しています。

フォームとバリデーション

shadcn/ui のフォームコンポーネントと React Hook Form を組み合わせて、堅牢なフォーム機能を実装します。

必要なパッケージをインストールします。

bashyarn add react-hook-form @hookform/resolvers zod
npx shadcn-ui@latest add form
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add select

バリデーションスキーマを定義します。

typescript// schemas/user-form.ts
import { z } from "zod"

export const userFormSchema = z.object({
  name: z.string().min(2, {
    message: "名前は2文字以上で入力してください。",
  }),
  email: z.string().email({
    message: "有効なメールアドレスを入力してください。",
  }),
  role: z.enum(["admin", "user", "moderator"], {
    required_error: "ロールを選択してください。",
  }),
  bio: z.string().max(500, {
    message: "自己紹介は500文字以内で入力してください。",
  }).optional(),
})

export type UserFormValues = z.infer<typeof userFormSchema>

Zod を使用したスキーマ定義により、型安全性とランタイムバリデーションの両方を確保できます。

ユーザー作成・編集フォームコンポーネントを実装します。

typescript// components/forms/user-form.tsx
'use client'

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { userFormSchema, UserFormValues } from "@/schemas/user-form"
import { User } from "@/types/user"

interface UserFormProps {
  user?: User
  onSubmit: (values: UserFormValues) => void
  isLoading?: boolean
}

export function UserForm({ user, onSubmit, isLoading = false }: UserFormProps) {
  const form = useForm<UserFormValues>({
    resolver: zodResolver(userFormSchema),
    defaultValues: {
      name: user?.name || "",
      email: user?.email || "",
      role: user?.role || "user",
      bio: "",
    },
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>名前</FormLabel>
              <FormControl>
                <Input placeholder="山田太郎" {...field} />
              </FormControl>
              <FormDescription>
                ユーザーの表示名を入力してください。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input
                  placeholder="example@company.com"
                  type="email"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                ログイン時に使用するメールアドレスです。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>ロール</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="ロールを選択してください" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="user">一般ユーザー</SelectItem>
                  <SelectItem value="moderator">モデレーター</SelectItem>
                  <SelectItem value="admin">管理者</SelectItem>
                </SelectContent>
              </Select>
              <FormDescription>
                ユーザーの権限レベルを設定します。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>自己紹介</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="自己紹介を入力してください..."
                  className="resize-none"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                プロフィールに表示される自己紹介文です(任意)。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <div className="flex justify-end space-x-2">
          <Button variant="outline" type="button">
            キャンセル
          </Button>
          <Button type="submit" disabled={isLoading}>
            {isLoading ? "保存中..." : user ? "更新" : "作成"}
          </Button>
        </div>
      </form>
    </Form>
  )
}

このフォームコンポーネントでは、各フィールドに適切なバリデーション、説明文、エラーメッセージを配置し、ユーザビリティの高いフォームを実現しています。

ダッシュボードとチャートの実装

管理画面のダッシュボードに表示する KPI カードとチャートコンポーネントを実装します。

まず、必要なコンポーネントを追加します。

bashnpx shadcn-ui@latest add card
yarn add recharts

KPI カードコンポーネントを作成します。

typescript// components/dashboard/kpi-card.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LucideIcon } from "lucide-react"

interface KPICardProps {
  title: string
  value: string
  description: string
  icon: LucideIcon
  trend?: {
    value: number
    isPositive: boolean
  }
}

export function KPICard({ 
  title, 
  value, 
  description, 
  icon: Icon, 
  trend 
}: KPICardProps) {
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium">
          {title}
        </CardTitle>
        <Icon className="h-4 w-4 text-muted-foreground" />
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        <div className="flex items-center space-x-2 text-xs text-muted-foreground">
          <span>{description}</span>
          {trend && (
            <span className={trend.isPositive ? "text-green-600" : "text-red-600"}>
              {trend.isPositive ? "+" : ""}{trend.value}%
            </span>
          )}
        </div>
      </CardContent>
    </Card>
  )
}

チャートコンポーネントを実装します。

typescript// components/dashboard/revenue-chart.tsx
'use client'

import {
  Line,
  LineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

const data = [
  { month: "1月", revenue: 12000 },
  { month: "2月", revenue: 15000 },
  { month: "3月", revenue: 18000 },
  { month: "4月", revenue: 22000 },
  { month: "5月", revenue: 20000 },
  { month: "6月", revenue: 25000 },
]

export function RevenueChart() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>月間売上推移</CardTitle>
      </CardHeader>
      <CardContent>
        <ResponsiveContainer width="100%" height={350}>
          <LineChart data={data}>
            <XAxis
              dataKey="month"
              stroke="#888888"
              fontSize={12}
              tickLine={false}
              axisLine={false}
            />
            <YAxis
              stroke="#888888"
              fontSize={12}
              tickLine={false}
              axisLine={false}
              tickFormatter={(value) => `¥${value.toLocaleString()}`}
            />
            <Tooltip
              content={({ active, payload, label }) => {
                if (active && payload && payload.length) {
                  return (
                    <div className="rounded-lg border bg-background p-2 shadow-sm">
                      <div className="grid grid-cols-2 gap-2">
                        <div className="flex flex-col">
                          <span className="text-[0.70rem] uppercase text-muted-foreground">
                            {label}
                          </span>
                          <span className="font-bold text-muted-foreground">
                            ¥{payload[0].value?.toLocaleString()}
                          </span>
                        </div>
                      </div>
                    </div>
                  )
                }
                return null
              }}
            />
            <Line
              type="monotone"
              dataKey="revenue"
              strokeWidth={2}
              activeDot={{
                r: 6,
                style: { fill: "var(--color-primary)" },
              }}
              style={{
                stroke: "var(--color-primary)",
              }}
            />
          </LineChart>
        </ResponsiveContainer>
      </CardContent>
    </Card>
  )
}

ダッシュボードページを統合します。

typescript// app/dashboard/page.tsx
import { Users, DollarSign, Package, Activity } from "lucide-react"
import { KPICard } from "@/components/dashboard/kpi-card"
import { RevenueChart } from "@/components/dashboard/revenue-chart"

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-3xl font-bold tracking-tight">ダッシュボード</h1>
        <p className="text-muted-foreground">
          システムの概要と主要な指標をご確認いただけます。
        </p>
      </div>

      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        <KPICard
          title="総売上"
          value="¥1,234,567"
          description="前月比"
          icon={DollarSign}
          trend={{ value: 12.3, isPositive: true }}
        />
        <KPICard
          title="アクティブユーザー"
          value="2,350"
          description="前月比"
          icon={Users}
          trend={{ value: 8.2, isPositive: true }}
        />
        <KPICard
          title="商品数"
          value="147"
          description="前月比"
          icon={Package}
          trend={{ value: -2.1, isPositive: false }}
        />
        <KPICard
          title="コンバージョン率"
          value="3.24%"
          description="前月比"
          icon={Activity}
          trend={{ value: 1.8, isPositive: true }}
        />
      </div>

      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
        <div className="col-span-4">
          <RevenueChart />
        </div>
        <div className="col-span-3">
          <Card>
            <CardHeader>
              <CardTitle>最近のアクティビティ</CardTitle>
            </CardHeader>
            <CardContent>
              <div className="space-y-4">
                <div className="flex items-center">
                  <div className="ml-4 space-y-1">
                    <p className="text-sm font-medium leading-none">
                      新規ユーザー登録
                    </p>
                    <p className="text-sm text-muted-foreground">
                      田中太郎さんが登録しました
                    </p>
                  </div>
                  <div className="ml-auto font-medium text-sm text-muted-foreground">
                    2分前
                  </div>
                </div>
                <div className="flex items-center">
                  <div className="ml-4 space-y-1">
                    <p className="text-sm font-medium leading-none">
                      商品更新
                    </p>
                    <p className="text-sm text-muted-foreground">
                      商品「サンプル商品A」が更新されました
                    </p>
                  </div>
                  <div className="ml-auto font-medium text-sm text-muted-foreground">
                    5分前
                  </div>
                </div>
              </div>
            </CardContent>
          </Card>
        </div>
      </div>
    </div>
  )
}

このダッシュボード実装により、視覚的にわかりやすい管理画面が完成します。Recharts を使用することで、レスポンシブなチャート表示も実現できるのです。

認証とルーティング

Next.js の App Router と NextAuth.js を使用して、認証機能を実装します。

必要なパッケージをインストールします。

bashyarn add next-auth
yarn add @auth/prisma-adapter prisma @prisma/client
yarn add bcryptjs
yarn add @types/bcryptjs

NextAuth.js の設定ファイルを作成します。

typescript// lib/auth.ts
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"

export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "jwt" },
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email }
        })

        if (!user) {
          return null
        }

        const isPasswordValid = await bcrypt.compare(
          credentials.password,
          user.password
        )

        if (!isPasswordValid) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      }
    })
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (token) {
        session.user.id = token.sub
        session.user.role = token.role
      }
      return session
    },
  },
  pages: {
    signIn: "/login",
  },
})

認証が必要なページを保護するミドルウェアを作成します。

typescript// middleware.ts
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { nextUrl } = req
  const isLoggedIn = !!req.auth

  const isAuthPage = nextUrl.pathname.startsWith('/login')
  const isProtectedRoute = nextUrl.pathname.startsWith('/dashboard')

  if (isAuthPage) {
    if (isLoggedIn) {
      return NextResponse.redirect(new URL('/dashboard', nextUrl))
    }
    return null
  }

  if (!isLoggedIn && isProtectedRoute) {
    return NextResponse.redirect(new URL('/login', nextUrl))
  }

  return null
})

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

この認証システムにより、適切な権限管理と画面遷移制御が実現できます。ミドルウェアによる保護により、未認証ユーザーは自動的にログイン画面にリダイレクトされるのです。

まとめ

開発効率の向上ポイント

Tailwind CSS と shadcn/ui を組み合わせた管理画面開発により、以下の効果を実現できました。

開発速度の劇的な向上が最も顕著な効果です。事前に設計されたコンポーネントを活用することで、UI 実装にかかる時間を大幅に短縮できます。また、Tailwind CSS のユーティリティクラスにより、CSS ファイルを別途作成する必要がなくなり、開発フローが簡素化されました。

コードの保守性向上も重要な成果です。コンポーネントベースの設計により、機能の追加や修正が局所的に行えるようになりました。また、TypeScript との組み合わせにより、型安全性が確保され、バグの早期発見が可能になっています。

デザインシステムの統一性確保により、チーム開発においても一貫性のある UI を維持できます。デザイントークンの活用により、色やスペーシングの統一が自動的に保たれるのです。

mermaidflowchart LR
  traditional[従来の開発] --> slow[開発が遅い]
  traditional --> inconsistent[不統一なデザイン]
  traditional --> maintenance[保守が困難]
  
  modern[Tailwind + shadcn/ui] --> fast[高速開発]
  modern --> consistent[統一されたデザイン]
  modern --> maintainable[保守しやすい]
  
  fast --> productivity[生産性向上]
  consistent --> ux[優れたUX]
  maintainable --> scalable[スケーラブル]

今後の拡張性について

構築した管理画面は、将来的な機能拡張に対して柔軟に対応できる設計になっています。

コンポーネントライブラリの拡張により、新しい UI パターンが必要になった際も、既存の設計思想を維持しながら追加実装が可能です。shadcn/ui のアーキテクチャにより、カスタムコンポーネントも統一感を保ちながら開発できるでしょう。

認証・認可システムの強化も容易に実現できます。ロールベースアクセス制御の詳細化や、多要素認証の導入なども、既存の認証基盤を活用して段階的に実装できます。

国際化対応についても、Next.js の i18n 機能と組み合わせることで、多言語対応の管理画面に発展させることが可能です。

今回構築した技術スタックは、モダンな Web 開発のベストプラクティスを取り入れており、長期的な運用においても技術的負債を最小限に抑えることができるでしょう。継続的な改善と機能追加により、より価値の高い管理画面へと発展させていくことができます。

関連リンク