React Router v7 × Shadcn によるモックアップ作成チュートリアル
はじめに
こんにちは、クラウドエースの第3開発部に所属している金です。
最近、技術検証のために簡単なモックアップを作成する機会が増えています。
この記事では、React Router version 7(以下、React Router v7) 、Shadcn、TailwindCSS を活用して、素早く品質の高いモックアップを作成する方法をご紹介します。
対象読者
- React Router v7 を体験してみたい方
- Remix に興味がある方
- 素早くモックアップを作成する必要がある方
- Tailwind CSS と shadcn/ui に興味がある方
事前準備
- React Router v7 のインストール
npx create-react-router@7.0.1 make-test
※ 本記事ではバージョンは 7.0.1 を使用
※ Node.js のバージョンは 20.11.0 を使用
- shadcn/ui のインストール
npx shadcn@canary init
※ Tailwind CSS version 4(以下、Tailwind v4) より動作不具合があった場合 Tailwind CSS version 3(以下、Tailwind CSS v3) にダウングレードして使用して下さい。
React Router
React Router とは
React Router は、React アプリケーションのルーティングを管理するための標準的なライブラリです。シングルページアプリケーション(SPA)で複数のページを扱うための機能を提供し、URL に基づいて適切なコンポーネントを表示する役割を担っています。
最新の動向
2024年 11月 Remix が React Router に統合されており、Remix v2 のユーザーには React Router v7 へのアップグレードを推奨しています。
ご参考: Remix v2 to React Router v7
使用方法
React Router は 2 つの使用方法があります。
-
ライブラリとしての使用 (React Router as a library)
- 従来通りルーティングライブラリとして使用可能
- URLとコンポーネントのマッチング、URL データへのアクセス、アプリ内ナビゲーションを提供
-
フレームワークとしての使用 (React Router as a framework)
- React Router CLI と Vite バンドラープラグインによるフルスタック開発アーキテクチャを提供
- SSR、データローディングなどの機能を提供
詳細についてはReact Router: API referenceをご参考ください。
※ 本記事ではフレームワーク(React Router as a framework)としての使用方法についてご説明します。
React Router version 7 の主な特徴
- サーバーサイドレンダリングに対応
- TypeScript を標準搭載
- 型の自動生成
- Tailwind CSS によるスタイリング
- shadcn/ui などの UI ライブラリとの相性の良さ
- SEO 対応
ルーティングの基本設定と構造
- プロジェクトの初期構成
React Router をインストールすると、以下の2つの重要なファイルが自動的に作成されます。
-
routes.ts
- アプリケーション全体のルーティング設定を管理する中核ファイル
- 主な役割:
- アプリケーションの画面構成の定義
- URLパスとコンポーネントの紐付け
- ネストされたルートの管理
- ルーティングの優先順位の設定
-
root.tsx
- アプリケーションのレイアウトを管理する基本コンポーネント
- 主な役割:
- 共通レイアウトの提供
- ページコンポーネントのレンダリング
- エラー処理の管理
- プロジェクト構造
本記事では、機能(ドメイン)ベースの構造(Feature-based Structure)を採用しています。
特徴は以下の通りですが、ご自身で合う構造でも構いません。- 関連する機能のコードを一箇所にまとめることができる
- 機能単位でのメンテナンスが容易
- コードの再利用性が向上
ご参考: Feature based structure in React
※ 注意事項:
-
routes.ts
とroot.tsx
は必須ファイルのため、削除しないでください。 - フォルダー構造はプロジェクトの要件に応じてカスタマイズ可能です。
- 自動生成されるDockerファイルとwelcomeフォルダーは本チュートリアルでは使用しません。
project-name/
├── app
│ ├── app.css(tailwind の設定)
│ ├── common
│ │ ├── components
│ │ │ ├── ui(shadcn/ui コンポーネント)
│ │ │ │ ├── button.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ └── ...
│ │ │ ├── navigationTopBar.tsx(ナビゲーションバー)
│ │ └── pages
│ │ ├── home.tsx(ホームページ)
│ ├── features
│ │ ├── products(商品関連)
│ │ │ ├── components
│ │ │ │ ├── productCard.tsx
│ │ │ └── pages
│ │ │ ├── productList.tsx
│ │ └── think(AI 関連)
│ │ ├── components
│ │ └── pages
│ │ ├── thinkHome.tsx
│ ├── lib
│ │ └── utils.ts
│ ├── root.tsx(レイアウト)
│ └── routes.ts(ルーティング)
├── components.json(shadcn/ui コンポーネントの設定)
├── react-router.config.ts(react router の設定)
├── tsconfig.json(typescript の設定)
└── vite.config.ts(vite の設定)
- ルーティングの処理フロー
ユーザーがブラウザで特定の URL(例:/about)にアクセスすると、以下の順序で処理が実行されます。
① ルートの照合
・React Router がroutes.ts
で定義されたルート設定を確認
・アクセスされたパスに対応するコンポーネントを特定
② コンポーネントの配置
・root.tsx
内の<Outlet>
が該当のページコンポーネントに置き換わる
・この時点でルートに定義された各種設定(メタデータなど)も適用
③ レイアウトの適用とレンダリング
・Layout コンポーネントによって共通レイアウトが適用
・最終的なページコンテンツがブラウザに表示
④ ルートの設定と実装
routes.ts
では以下の関数を使用してルートを設定します。
・index()
: ルートページの設定
・route()
:
・個別ページの追加
・第一引数はページのパス、第二引数はページのコンポーネントを設定
// app/routes.ts
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [
//`/` にアクセスす → /common/pages/Home.tsx が表示される。
index("common/pages/Home.tsx"),
//`/about` にアクセスす → /common/pages/About.tsx が表示される。
route("/about", "common/pages/About.tsx"),
] satisfies RouteConfig;
※ 上記で指定するコンポーネントはかならず export default
でエクスポートして下さい。
- 階層的なルート管理:
-
複数のルートを同じカテゴリ(例:products)にまとめる場合、
prefix
を使用することで、コードの可読性と保守性が向上します。 -
例えば、以下のようなルート設定があるとします。
export default [ index("common/pages/Home.tsx"), route("/about", "common/pages/About.tsx"), route("products/list", "features/products/pages/ProductList.tsx"), route("products/info", "features/products/pages/ProductInfo.tsx"), ] satisfies RouteConfig;
-
prefix()
を使用すると、同じカテゴリのルートをまとめて管理できます。export default [ index("common/pages/Home.tsx"), route("/about", "common/pages/About.tsx"), ...prefix("products", [ route("/list", "features/products/pages/ProductList.tsx"), route("/info", "features/products/pages/ProductInfo.tsx"), ]), ] satisfies RouteConfig;
/products
パスにアクセスした場合、対応するコンポーネント/features/products/pages/ProductList.tsx
が画面に表示されます。 -
prefix()
の中にindex()
をネストするとこも可能です。export default [ index("common/pages/Home.tsx"), ...prefix("products", [ index("features/products/pages/ProductHome.tsx"), ...prefix("report-dashboard", [ index("features/products/pages/ProductReportHome.tsx"), route("/year-report/:year", "features/products/pages/ProductReportYear.tsx"), ]), ]), ] satisfies RouteConfig;
上記のルート設定により、以下のように URL とコンポーネントが紐付けられます.
アクセスする URL | 表示されるコンポーネント |
---|---|
/ |
/common/pages/Home.tsx |
/products |
/features/products/pages/ProductHome.tsx |
/products/report-dashboard |
/features/products/pages/ProductReportHome.tsx |
/products/report-dashboard/year-report/2025 |
/features/products/pages/ProductReportYear.tsx |
Layout
Layout は、ユーザーに表示すべきページをレンダリングする基本的な役割以外にも、重要な機能を担っています。
例えば、ページコンポーネント(/profile/about.tsxなど)でエラーが発生した場合の処理を管理します。エラーが発生すると、ErrorBoundary()
が自動的にレンダリングされ、適切なエラー表示を行います。
以下は root.tsx
の Layout コードの一部です。
// app/root.tsx
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// 省略...
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
Layout の主な機能まとめ:
-
エラーハンドリング
- ページコンポーネントでエラーが発生した際に、
ErrorBoundary()
を通じて適切なエラー表示を自動的に行う
- ページコンポーネントでエラーが発生した際に、
-
ページ設定の管理
- メタデータとリンクの管理(
<Meta />
と<Links />
) - スクロール位置の保持(
<ScrollRestoration />
)
- メタデータとリンクの管理(
-
共通レイアウトの提供
- ヘッダー、フッターなどの共通要素の維持
- ページコンテンツの動的な更新
補足:<Meta />
と <Links />
の設定
- ページごとに必要なリンクやメタデータを設定するには、以下のような形でエクスポートします。これにより、各ページに最適化されたメタ情報を提供することができます。
// メタデータとリンクの設定例
export const links = () => [{ rel: "stylesheet", href: "test.com" }];
export const meta = () => [
// ページごとのメタデータを設定
{ title: "test meta title" }, // ブラウザのタブに title が表示される
{ name: "description", content: "test meta description" }, // メタデータ
];
フレームワークの設定
react-router.config.ts
ファイルでは、デフォルトでサーバーサイドレンダリングが設定されています。
false
に設定すると、クライアントサイドレンダリングに切り替わります。
データの取得(Data Fetching)
React Router v7 では、loader()
関数を使用してデータを取得(Data Fetching)することができます。
loader()
関数の特徴:
- サーバーサイドで実行され、パフォーマンスが向上
- 従来のuseEffectやローディング状態の管理が不要
- 型安全性が保証される
使用例:
export async function loader() {
console.log("work loader!");
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const posts = await response.json();
return {
message: "こんにちは!",
posts,
};
}
export default function Home({
loaderData,
}: Route.ComponentProps) {
return (
<div className="px-15 py-10">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1 items-start">
<h2 className="text-4xl font-bold">Test Products</h2>
<p className="text-sm font-light">
{loaderData.message} テスト商品です。
</p>
<p>{loaderData.posts.title}</p>
...省略
loader は UI コンポーネントを使用せずに、以下のように実装できます。
export async function loader() {
return redirect('/products/boards');
}
上記のコードでは、例えば /
へのアクセスを /products/boards
にリダイレクトするように設定することも可能です。
データローディング
React Router v7 から useNavigation()
を使ってデータローディングの処理をすることも可能です
// app/root.tsx
// ローディングの処理の例
import { useNavigation } from "react-router";
export default function App() {
const isLoading = useNavigation()
return (
// ローディングの時には opacity-50 を適用
<div className={isLoading.state === "loading" ? "opacity-50" : "py-16"}>
<NavigationTopBar
isLogin={true}
hasNotification={true}
hasMessage={true}
/>
<Outlet />
</div>
);
}
他に React.js の <Suspense>
を使ってローディングの処理をすることも可能です。
<Suspense fallback={<div>Loading...</div>}>
</Suspense>
データが重くないかつすぐページを見せたい場合は prefetch = intent
を使ってデータをプリフェッチすることも可能です。
以下の例ではナビゲーションメニューのリンクにマウスを当てるとデータがプリフェッチされます。
<NavigationMenu>
<NavigationMenuList>
{menus.map((menu) => (
<NavigationMenuItem key={menu.name}>
{menu.items ? (
<>
<Link to={menu.to} prefetch="intent">
<NavigationMenuTrigger>{menu.name}</NavigationMenuTrigger>
</Link>
..省略
Data Loading については以下のドキュメントをご参考ください。
React Router: Data Loading
※ 型安全性(type safety)について
React Router v7 では、型の自動生成機能が組み込まれています。npm run dev
を実行すると、.react-router
フォルダが自動的に生成され、各コンポーネントに対応した型定義ファイルが作成されます。
例えば、上記の Home コンポーネントの型定義は以下のパスに生成されます。
.react-router/types/app/common/pages/+types/Home.ts
コンポーネントで Route.ComponentProps
を使用するだけで、loaderDataの型が自動的に推論され、型安全性が確保されます。
ご参考: React Router v7: type safety
以上で React Router v7 の基本的な使い方を説明しました。
より詳細な使い方についてはReact Router: API referenceをご参考ください。
次は shadcn/ui の活用について説明します。
shadcn/ui
shadcn/ui とは
shadcn/ui は、Tailwind CSS をベースにした特殊なコンポーネントライブラリです。通常の npm パッケージとは異なり、必要なコンポーネントのみを直接プロジェクトにコピーして使用できます。
セットアップと使用方法が非常に簡単で、以下の手順で始められます。
React Router v7 プロジェクトのルートディレクトリで、次のコマンドを実行してインストールします。
npx shadcn@canary init
【重要な注意点】
• React Router v7 でプロジェクトを作成すると、Tailwind CSS version 4 が自動的にインストールされます。この場合、以下のいずれかの対応が必要です。
-
npx shadcn@canary init
を使用してインストール - Tailwind CSS を version 3 にダウングレード
※ npx shadcn@latest init
は Tailwind CSS version 4 と互換性がないためご注意ください。(2025/02/28 基準)
ご参考: An update on Tailwind v4
Theme Customizer
shadcn/ui は公式サイトでテーマカスタマイザーを提供しており、コンポーネント全体のカラースキームを簡単に設定できます。
ご参考: shadcn/ui Theme Customizer
上記での色を設定後、Copy code で app.css
にコピーして適用します。
※ 注意:
Tailwind CSS v4 をご利用の場合は、@layer base {}
内の css
をコピーして使用してください。
--background: 0 0% 100%;
は --background: hsl(20 14.3% 4.1%);
の形式で記述する必要があります。異なる形式では、エラーが発生します。
ダークモードの設定
html 要素に "dark"
クラスを追加することで、ダークモードを適用できます。
<!-- /app/root.tsx -->
<html lang="ja" className="dark"></html>
コンポーネントのスタイリング
shadcn/ui の各コンポーネントは、以下の方法でスタイリングをカスタマイズできます。
• className props を使用した直接的なスタイリング
• Tailwind CSS のユーティリティクラスの適用
• テーマ変数による一貫したデザインの適用
実践例:ナビゲーションメニューの実装
まずは必要なコンポーネントのインストールします。
以下のコマンドで必要なコンポーネントをプロジェクトに追加します。
npx shadcn@latest add [コンポーネント名]
※ コンポーネントコードについて
- shadcn/ui のコンポーネントは以下のコマンドで自動生成されるので、個々のコンポーネントコードの詳細は省略させていただきます。
npx shadcn@latest add [component name]
※ 本記事では、代表的なコンポーネントを使用して基本的な機能を解説します。
これらのコンポーネントの使い方を理解することで、他のコンポーネントも容易に活用できるようになるかと思います。
より詳しい情報は、shadcn/ui - Componentsの公式ドキュメントをご参照ください。
// ナビゲーションメニュー
npx shadcn@latest add navigation-menu
// セパレーター
npx shadcn@latest add separator
// ボタン
npx shadcn@latest add button
// ドロップダウンメニュー
npx shadcn@latest add dropdown-menu
// アバター
npx shadcn@latest add avatar
// カード
npx shadcn@latest add card
shadcn/ui のコンポーネントは、className プロパティを使用してスタイルをカスタマイズすることができます。
また、asChild プロパティを使うことで、親コンポーネントのスタイルを子コンポーネントに引き継ぐことができます。
以下は asChild プロパティを使用したリンクの実装例です。
<NavigationMenuLink asChild>
<Link to={item.to}>{item.name}</Link>
</NavigationMenuLink>
Link は NavigationMenuLink のスタイルを継承した Link コンポーネントになります。
条件付きクラス名をより良い方法で処理するには、以下を使用しましょう。
lib/utils.ts
には shadcn/ui が提供するユーティリティ関数があります。
shadcn/ui をインストールすると自動で作成されます。
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
例えば、以下の shadcn/ui の NavigationMenu コンポーネントで書かれた条件分岐コードはわかりにくいです。
<NavigationMenuItem
key={item.name}
className={
`select-none rounded-md transition-colors focus:bg-accent hover:bg-accent
${item.to === "/products/categories" ? "bg-accent" : ""}`
}
>...
次のように cn 関数を使うと、よりわかりやすくなります。
<NavigationMenuItem
key={item.name}
className={cn(
"select-none rounded-md transition-colors focus:bg-accent hover:bg-accent",
item.to === "/products/categories" && "bg-accent"
)}
>省略...
NavigationMenu コンポーネントは、トリガー機能を省いてタブのような見た目だけを維持し、それをリンクとして使用することもできます。
/components/ui/navigation-menu.tsx
からインポートしたスタイルを関数として使用できます。
※ navigation-menu.tsx は shadcn/ui のコンポーネントです。
/// navigation-menu.tsx
// shadcn/ui のナビゲーションメニューのスタイル
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
)```
```tsx
// 基本的な使用例
<Link className={navigationMenuTriggerStyle()} to={menu.name}>{menu.name}</Link>
shadcn/ui で使う className の中で foreground があります。
foreground を使うと背景色に合わせてみやすに色になります。
<NavigationMenuLink asChild>
<Link
className="p-3 block no-underline outline-none leading-none "
to={item.to}
>
{/* primary は https://ui.shadcn.com/themes でカスタマイズしたカラー */}
<span className="text-sm font-bold text-primary-foreground">
{item.name}
</span>
<p className="text-muted-foreground">{item.description}</p>
</Link>
</NavigationMenuLink>
Link にボタンのスタイルを適用するには以下のようにします。
<Button asChild>
<Link to="/login">Login</Link>
</Button>
※ asChild
は一個の子コンポーネントのみ適用できます。
// NG例
<Button className="relative" asChild>
<Link to="/login">Login</Link>
{isLogin && <div className="absolute right-0 top-0 bg-red-300" />}
</Button>
// OK 例
<Button className="relative" asChild>
<Link to="/login">
Login
{isLogin && <div className="absolute right-0 top-0 bg-red-300" />}
</Link>
</Button>
他の方法は className
に buttonVariants
を適用するとボタンのスタイルを適用できます。
<Link to="/login" className={buttonVariants({ variant: "default" })}>
Login
</Link>
AvatarFallback コンポーネントは、ユーザーのアバターが表示されない場合のフォールバックとして表示されるコンポーネントです。
※ 以下のコードでは、アバターが表示されない場合に CN が表示されます。
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
ヘッダーのナビゲーションメニューのサンプルコードは以下です。
// app/common/components/navigationTopBar.tsx
import { Link } from "react-router";
import { cn } from "~/lib/utils";
import { Separator } from "./ui/separator";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
import { NavigationMenuList } from "./ui/navigation-menu";
import { Button } from "./ui/button";
import {
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "./ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import {
BellIcon,
LogOutIcon,
MessageCircleIcon,
UserIcon,
} from "lucide-react";
// ナビゲーションメニューのデータ
const menus = [
{
name: "Products Div.",
to: "/products",
items: [
{
name: "Categories",
description: "See categories",
to: "/products/categories",
},
{
name: "Search",
description: "Search for a product",
to: "/products/search",
},
{
name: "Assignments",
description: "assign tasks to your team",
to: "/products/assignments",
},
],
},
{
name: "think with GPT",
to: "/think",
},
];
export default function NavigationTopBar({
isLogin,
hasNotification,
hasMessage,
}: {
isLogin: boolean;
hasNotification: boolean;
hasMessage: boolean;
}) {
return (
<nav className="flex px-10 h-17 items-center justify-between backdrop-blur fixed top-0 left-0 right-0 z-10 bg-green-700/30">
<div className="flex items-center">
<Link to="/" className="tracking-tighter text-lg font-bold">
Home
</Link>
<Separator orientation="vertical" className="h-7 mx-3 bg-white" />
{/* ナビゲーションメニュー */}
<NavigationMenu>
{/* ナビゲーションメニューのリスト */}
<NavigationMenuList>
{menus.map((menu) => (
// ナビゲーションメニューのアイテム
<NavigationMenuItem key={menu.name}>
{menu.items ? (
<>
<Link to={menu.to} prefetch="intent">
<NavigationMenuTrigger>{menu.name}</NavigationMenuTrigger>
</Link>
<NavigationMenuContent>
<ul className="grid w-[400px] font-bold gap-2 p-4 grid-cols-2">
{menu.items?.map((item) => (
<NavigationMenuItem
key={item.name}
className={cn([
"select-none rounded-md",
item.to === "/products/categories" &&
"bg-accent"
])}
>
<NavigationMenuLink asChild>
<Link
className="p-3 block no-underline outline-none leading-none"
to={item.to}
>
<span className="text-sm font-bold">
{item.name}
</span>
<p className="text-muted-foreground">
{item.description}
</p>
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</ul>
</NavigationMenuContent>
</>
) : (
<Link className={navigationMenuTriggerStyle()} to={menu.to}>
{menu.name}
</Link>
)}
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
{/* ログインしている場合 */}
{isLogin ? (
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild className="relative">
<Link to="/private/notifications">
<BellIcon className="w-4 h-4 " />
{hasNotification && (
<div className="absolute right-0 top-0 w-2 h-2 bg-red-300 rounded-full" />
)}
</Link>
</Button>
<Button variant="ghost" size="icon" asChild className="relative">
<Link to="/private/messages">
<MessageCircleIcon className="w-4 h-4 relative" />
{hasMessage && (
<div className="absolute right-0 top-0 bg-red-300 w-2 h-2 rounded-full" />
)}
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-55">
<DropdownMenuLabel className="flex flex-col gap-1">
<span className="text-sm font-bold">shadcn kun</span>
<span className="text-xs text-muted-foreground">
shadcn@gmail.com
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/private/profile">
<UserIcon className="w-4 h-4 mr-2" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/authentication/logout">
<LogOutIcon className="w-4 h-4 mr-2" />
Logout
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
// ログインしていない場合
<div className="flex items-center gap-3">
<Button asChild variant="outline">
<Link to="/login">Login</Link>
</Button>
<Button asChild>
<Link to="/signup">Signup</Link>
</Button>
</div>
)}
</nav>
);
}
ナビゲーションメニューのサンプルコード以外のコードは以下です。
・メインページと関連コンポーネント:
// app/common/pages/Home.tsx
import type { MetaFunction } from "react-router";
import { ProductCard } from "~/features/products/components/ProductCard";
import { Button } from "../components/ui/button";
import { Link } from "react-router";
import { ThinkWithAICard } from "~/features/think/components/ThinkWithAICard";
import type { Route } from "./+types/Home";
export const meta: MetaFunction = () => {
return [
{ title: "Home | TEST " }, // ブラウザのタブに "Home | TEST" として表示
{ name: "description", content: "Welcome to the home page" }, // メタデータ
];
};
// data fetching 処理
export async function loader() {
console.log("work loader!");
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const posts = await response.json();
return {
message: "こんにちは!",
posts,
};
}
export default function Home({
loaderData,
}: Route.ComponentProps) {
return (
<div className="px-15 py-10">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1 items-start">
<h2 className="text-4xl font-bold">Test Products</h2>
<p className="text-sm font-light">
{loaderData.message} テスト商品です。
</p>
<p>{loaderData.posts.title}</p>
<Button variant="link" className="text-xl p-0 font-light" asChild>
<div className="flex gap-2">
<Link to="/products/board">全体商品を見る </Link>
</div>
</Button>
</div>
{Array.from({ length: 10 }).map((_, index) => (
<ProductCard
key={index}
id={`${index}`}
title="Test Product"
description="テスト商品詳細"
commentCount={100}
viewCount={100}
/>
))}
</div>
<div className="grid grid-cols-3 gap-4 mt-10">
<div className="flex flex-col gap-1 items-start">
<h2 className="text-4xl font-bold">Think with GPT</h2>
<p className="text-sm font-light">テストアイディア。</p>
<Button variant="link" className="text-xl p-0 font-light" asChild>
<div className="flex gap-2">
<Link to="/think">全体アイディアを見る </Link>
</div>
</Button>
</div>
{Array.from({ length: 10 }).map((_, index) => (
<ThinkWithAICard
key={index}
id={`${index}`}
title="AI サポートツールアイデア"
viewCount={10}
timeCount={10}
/>
))}
</div>
</div>
);
}
// app/features/products/components/ProductCard.tsx
import { Card, CardTitle, CardHeader, CardDescription } from "~/common/components/ui/card";
import { EyeIcon, MessageCircleIcon } from "lucide-react";
import { Link } from "react-router";
interface ProductCardProps {
id: string;
title: string;
description: string;
commentCount: number;
viewCount: number;
}
export function ProductCard({ id, title, description, commentCount, viewCount }: ProductCardProps) {
return (
<Link to={`/products/${id}`}>
<Card className="w-full flex items-center justify-between hover:bg-primary/50">
<CardHeader>
<CardTitle className="text-xl font-bold">{title}</CardTitle>
<CardDescription className="text-sm">
{description}
</CardDescription>
<div className="flex items-center gap-4 mt-3">
<div className="flex items-center gap-1">
<MessageCircleIcon className="w-4 h-4" />
<span className="text-sm">{commentCount}</span>
</div>
<div className="flex items-center gap-1">
<EyeIcon className="w-4 h-4" />
<span className="text-sm">{viewCount}</span>
</div>
</div>
</CardHeader>
</Card>
</Link>
);
}
//app/features/think/components/ThinkWithAICard.tsx
import { Card, CardTitle, CardHeader, CardContent } from "~/common/components/ui/card";
import { EyeIcon, TimerIcon } from "lucide-react";
import { Link } from "react-router";
interface ThinkWithAICardProps {
id: string;
title: string;
viewCount: number;
timeCount: number;
}
export function ThinkWithAICard({ id, title, viewCount, timeCount }: ThinkWithAICardProps) {
return (
<Card className="w-full flex hover:bg-primary/50">
<CardHeader>
<Link to={`/think/${id}`}>
<CardTitle className="text-xl font-bold">
{title}
</CardTitle>
</Link>
</CardHeader>
<CardContent className="flex items-center gap-2">
<div className="text-sm font-light flex items-center gap-1">
<EyeIcon className="w-4 h-4" />
<span className="text-sm">{viewCount} views</span>
</div>
<div className="text-sm font-light flex items-center gap-1">
<TimerIcon className="w-4 h-4" />
<span className="text-sm">{timeCount} hours ago</span>
</div>
</CardContent>
</Card>
);
}
・Product メニューページと関連コンポーネント:
// app/features/products/pages/ProductHome.tsx
import type { MetaFunction } from "react-router";
import { Card, CardHeader, CardTitle, CardContent } from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { Link } from "react-router";
import { ArrowRightIcon, TagIcon, SearchIcon, ClipboardListIcon } from "lucide-react";
export const meta: MetaFunction = () => {
return [
{ title: "Products | Home" },
{ name: "description", content: "製品管理システムのホームページ" },
];
};
export default function ProductHome() {
const menuItems = [
{
title: "カテゴリー管理",
description: "製品カテゴリーの閲覧と管理",
icon: TagIcon,
to: "/products/categories",
},
{
title: "製品検索",
description: "製品の検索と詳細確認",
icon: SearchIcon,
to: "/products/search",
},
{
title: "タスク管理",
description: "製品関連タスクの割り当てと管理",
icon: ClipboardListIcon,
to: "/products/assignments",
},
];
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-2">製品管理システム</h1>
<p className="text-muted-foreground">製品の管理、検索、タスク割り当てを行うことができます。</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{menuItems.map((item) => (
<Link key={item.to} to={item.to} className="block">
<Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader>
<div className="flex items-center gap-2">
<item.icon className="w-5 h-5" />
<CardTitle>{item.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">{item.description}</p>
<Button variant="ghost" className="group">
アクセス
<ArrowRightIcon className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
// app/features/products/pages/categoriesPage.tsx
import type { MetaFunction } from "react-router";
import { Card, CardHeader, CardTitle, CardContent } from "~/common/components/ui/card";
export const meta: MetaFunction = () => {
return [
{ title: "Categories | Product Categories" },
{ name: "description", content: "Browse our product categories" },
];
};
export default function CategoriesPage() {
const categories = [
{ id: 1, name: "Electronics", description: "Gadgets and devices" },
{ id: 2, name: "Clothing", description: "Fashion items" },
{ id: 3, name: "Books", description: "Books and publications" },
];
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-6">Product Categories</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map((category) => (
<Card key={category.id}>
<CardHeader>
<CardTitle>{category.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{category.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// app/features/products/pages/searchPage.tsx
import type { MetaFunction } from "react-router";
import { Input } from "~/common/components/ui/input";
import { Button } from "~/common/components/ui/button";
import { SearchIcon } from "lucide-react";
export const meta: MetaFunction = () => {
return [
{ title: "Search Products" },
{ name: "description", content: "Search for products in our catalog" },
];
};
export default function SearchPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-6">Search Products</h1>
<div className="max-w-2xl mx-auto">
<div className="flex gap-4">
<Input
type="search"
placeholder="Search for products..."
className="flex-1"
/>
<Button>
<SearchIcon className="w-4 h-4 mr-2" />
Search
</Button>
</div>
<div className="mt-8">
{/* Search results will be displayed here */}
</div>
</div>
</div>
);
}
// app/features/products/pages/AssignmentsPage.tsx
import type { MetaFunction } from "react-router";
import { Card, CardHeader, CardTitle, CardContent } from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { PlusIcon, UserIcon } from "lucide-react";
export const meta: MetaFunction = () => {
return [
{ title: "Task Assignments" },
{ name: "description", content: "Manage and view task assignments" },
];
};
export default function AssignmentsPage() {
const assignments = [
{ id: 1, task: "Review Product Specs", assignee: "John Doe", dueDate: "2024-03-20" },
{ id: 2, task: "Update Inventory", assignee: "Jane Smith", dueDate: "2024-03-22" },
];
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-4xl font-bold">Task Assignments</h1>
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
New Assignment
</Button>
</div>
<div className="grid gap-4">
{assignments.map((assignment) => (
<Card key={assignment.id}>
<CardHeader>
<CardTitle>{assignment.task}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>{assignment.assignee}</span>
</div>
<p className="text-muted-foreground mt-2">Due: {assignment.dueDate}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
・AI関連ページ:
// app/features/think/pages/ThinkHome.tsx
import type { MetaFunction } from "react-router";
import { Card, CardHeader, CardTitle, CardContent } from "~/common/components/ui/card";
import { Button } from "~/common/components/ui/button";
import { BrainIcon } from "lucide-react";
export const meta: MetaFunction = () => {
return [
{ title: "Think with AI" },
{ name: "description", content: "Generate and explore ideas with AI" },
];
};
export default function ThinkPage() {
const ideas = [
{
id: 1,
title: "AIサポートツールのアイデア",
description: "業務効率化のためのAIツール開発案",
},
{
id: 2,
title: "自動化システムの提案",
description: "ルーチンワークの自動化について",
},
];
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-4xl font-bold">Think with AI</h1>
<Button>
<BrainIcon className="w-4 h-4 mr-2" />
New Idea
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ideas.map((idea) => (
<Card key={idea.id} className="hover:bg-accent/50 transition-colors">
<CardHeader>
<CardTitle>{idea.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{idea.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}
ルーティング関連:
// app/routes.tsx
import { type RouteConfig, index, prefix, route } from "@react-router/dev/routes";
export default [
// ホームページ
index("common/pages/Home.tsx"),
// products 関連ページ
...prefix("products", [
// products ホームページ
index("features/products/pages/ProductHome.tsx"),
// カテゴリー関連ページ
route("/categories", "features/products/pages/CategoriesPage.tsx"),
// 検索関連ページ
route("/search", "features/products/pages/SearchPage.tsx"),
// タスク関連ページ
route("/assignments", "features/products/pages/AssignmentsPage.tsx"),
]),
// AI関連ページ
route("/think", "features/think/pages/ThinkPage.tsx"),
] satisfies RouteConfig;
・root ページ:
// app/root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import NavigationTopBar from "./common/components/NavigationTopBar";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
// エラーがない時に見せたい場合
const isLoading = useNavigation()
return (
<div className={isLoading.state === "loading" ? "opacity-50" : "py-16"}>
<NavigationTopBar
isLogin={true}
hasNotification={true}
hasMessage={true}
/>
<Outlet />
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
・app.css ファイル:
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(142.1 76.2% 36.3%);
--primary-foreground: hsl(355.7 100% 97.3%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(142.1 76.2% 36.3%);
--radius: 0.5rem;
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
}
.dark {
--background: hsl(20 14.3% 4.1%);
--foreground: hsl(0 0% 95%);
--card: hsl(24 9.8% 10%);
--card-foreground: hsl(0 0% 95%);
--popover: hsl(0 0% 9%);
--popover-foreground: hsl(0 0% 95%);
--primary: hsl(142.1 70.6% 45.3%);
--primary-foreground: hsl(144.9 80.4% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 15%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(12 6.5% 15.1%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 85.7% 97.3%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(142.4 71.8% 29.2%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
・package.json ファイル:
{
"name": "test-react-router-v7",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@react-router/node": "^7.0.1",
"@react-router/serve": "^7.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.17",
"lucide-react": "^0.476.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@react-router/dev": "^7.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"react-router-devtools": "^1.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
}
※ アイコンライブラリについて
- shadcn/ui のインストール時にLucide React が自動的に導入されます。
- Lucide React は 1,540 以上の豊富なアイコンを提供するReact向けライブラリです。
- アイコン一覧:Lucide React Icons
サンプルの全体イメージ:
まとめ
長い説明になりましたが、ここまでご紹介した内容を活用いただくことで、迅速かつ品質の高いモックアップ作成にお役立ていただけるかと思います。
補足として、Cursor などの生成 AI ツールを併用する場合は、routes.ts
でアプリケーション構造を定義し、それをもとにコードを生成することで、さらなる開発効率の向上も期待できます。
このアプローチは、特に技術検証やプロトタイプの作成において高い効果を発揮します。
より詳細な情報については、以下の公式ドキュメントをご参照ください。
大規模なアプリケーションの開発では Next.js が適していますが、それ以外のケースでは React Router v7 の方が開発効率が高いケースが多いと考えられます。
本チュートリアルが皆様の開発にお役立てば幸いです。
最後までお読みいただき、ありがとうございました。
Discussion