Airbnb風宿泊検索サイトの実装 - Next.js 14 + App Router + Shadcn + Supabase + Prisma
画像を忠実に再現した宿泊検索サイトを、最新のNext.js 14とApp Router、Shadcn UI、Supabase、Prismaを使って実装ほうほ。
プロジェクト構成
airbnb-clone/
├── .env # 環境変数
├── .env.example # 環境変数のサンプル
├── prisma/
│ ├── schema.prisma # Prismaスキーマ
│ └── seed.ts # サンプルデータ作成スクリプト
├── public/
│ └── images/
│ ├── logo.png
│ ├── categories/ # カテゴリーアイコン
│ └── accommodations/ # 宿泊施設画像
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── search/
│ │ │ └── route.ts # 検索API
│ │ ├── search/
│ │ │ └── page.tsx # 検索結果ページ
│ │ ├── layout.tsx # ルートレイアウト
│ │ └── page.tsx # ホームページ
│ ├── components/
│ │ ├── ui/ # Shadcn UIコンポーネント
│ │ ├── layout/
│ │ │ ├── header.tsx
│ │ │ └── footer.tsx
│ │ ├── search/
│ │ │ ├── search-form.tsx
│ │ │ └── search-results.tsx
│ │ ├── accommodation/
│ │ │ └── accommodation-card.tsx
│ │ └── category/
│ │ └── category-list.tsx
│ ├── lib/
│ │ ├── prisma.ts # Prismaクライアント
│ │ ├── supabase.ts # Supabaseクライアント
│ │ └── utils.ts # ユーティリティ関数
│ ├── types/
│ │ └── index.ts # 型定義
│ └── actions/
│ └── search-actions.ts # サーバーアクション
└── package.json
環境設定
1. プロジェクト作成
npx create-next-app@latest airbnb-clone --typescript --tailwind --app
cd airbnb-clone
2. 必要なパッケージのインストール
# Shadcn UI
npm install shadcn-ui @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-toggle @radix-ui/react-checkbox @radix-ui/react-select @radix-ui/react-tabs class-variance-authority clsx tailwind-merge lucide-react
# Prisma
npm install prisma @prisma/client
npx prisma init
# Supabase
npm install @supabase/supabase-js
# その他のユーティリティ
npm install date-fns react-day-picker react-icons
3. Shadcn UIのセットアップ
npx shadcn-ui@latest init
設定オプション:
- Style: Default
- Base color: Slate
- Global CSS: src/app/globals.css
- CSS variables: Yes
- React Server Components: Yes
- Components directory: @/components/ui
- Utility folder: @/lib/utils
4. Supabaseのセットアップ
- Supabaseアカウントを作成し、新しいプロジェクトを作成
-
.env
ファイルに以下の環境変数を追加:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-url.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
5. Prismaのセットアップ
prisma/schema.prisma
を編集:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Category {
id Int @id @default(autoincrement())
name String @unique
iconUrl String
accommodations Accommodation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Accommodation {
id Int @id @default(autoincrement())
name String
description String?
prefecture String
city String
imageUrls String[]
pricePerNight Decimal @db.Decimal(10, 2)
rating Float
reviewCount Int @default(0)
guestChoice Boolean @default(false)
availableDateFrom DateTime
availableDateTo DateTime
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
データベースURLを.env
に追加:
DATABASE_URL="postgresql://postgres:password@localhost:5432/airbnb_clone"
Supabaseを使用する場合は、Supabaseが提供するPostgreSQLのURLを使用します。
6. Prismaクライアントの生成とデータベースマイグレーション
npx prisma migrate dev --name init
実装コード
1. Prismaクライアント設定
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;
2. Supabaseクライアント設定
// src/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!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
3. 型定義
// src/types/index.ts
export type Category = {
id: number;
name: string;
iconUrl: string;
};
export type Accommodation = {
id: number;
name: string;
description?: string;
prefecture: string;
city: string;
imageUrls: string[];
pricePerNight: number;
rating: number;
reviewCount: number;
guestChoice: boolean;
availableDateFrom: Date;
availableDateTo: Date;
categoryId: number;
category?: Category;
};
export type SearchCriteria = {
location?: string;
checkInDate?: Date;
checkOutDate?: Date;
guestCount?: number;
categoryId?: number;
};
4. サーバーアクション
// src/actions/search-actions.ts
'use server';
import { prisma } from '@/lib/prisma';
import { SearchCriteria } from '@/types';
import { revalidatePath } from 'next/cache';
export async function searchAccommodations(criteria: SearchCriteria) {
try {
const { location, checkInDate, checkOutDate, categoryId } = criteria;
const whereClause: any = {};
if (location) {
whereClause.OR = [
{ prefecture: { contains: location, mode: 'insensitive' } },
{ city: { contains: location, mode: 'insensitive' } }
];
}
if (checkInDate && checkOutDate) {
whereClause.AND = [
{ availableDateFrom: { lte: checkOutDate } },
{ availableDateTo: { gte: checkInDate } }
];
}
if (categoryId) {
whereClause.categoryId = categoryId;
}
const accommodations = await prisma.accommodation.findMany({
where: whereClause,
include: {
category: true
},
orderBy: {
rating: 'desc'
}
});
return accommodations;
} catch (error) {
console.error('Search error:', error);
throw new Error('Failed to search accommodations');
}
}
export async function getCategories() {
try {
const categories = await prisma.category.findMany({
orderBy: {
name: 'asc'
}
});
return categories;
} catch (error) {
console.error('Error fetching categories:', error);
throw new Error('Failed to fetch categories');
}
}
export async function getAccommodationsByCategory(categoryId: number) {
try {
const accommodations = await prisma.accommodation.findMany({
where: {
categoryId
},
include: {
category: true
},
orderBy: {
rating: 'desc'
}
});
revalidatePath('/');
return accommodations;
} catch (error) {
console.error('Error fetching accommodations by category:', error);
throw new Error('Failed to fetch accommodations by category');
}
}
5. レイアウトとページ
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Header from '@/components/layout/header';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Airbnb Clone',
description: '宿泊施設検索サイト',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
<Header />
<main>{children}</main>
</body>
</html>
);
}
// src/app/page.tsx
import { getCategories, searchAccommodations } from '@/actions/search-actions';
import AccommodationCard from '@/components/accommodation/accommodation-card';
import CategoryList from '@/components/category/category-list';
import SearchForm from '@/components/search/search-form';
export default async function Home() {
const categories = await getCategories();
const featuredAccommodations = await searchAccommodations({
// 初期表示用のおすすめ宿泊施設
});
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center mb-8">
<SearchForm />
</div>
<CategoryList categories={categories} />
<section className="mt-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{featuredAccommodations.map((accommodation) => (
<AccommodationCard
key={accommodation.id}
accommodation={accommodation}
/>
))}
</div>
</section>
</div>
);
}
// src/app/search/page.tsx
import { getCategories, searchAccommodations } from '@/actions/search-actions';
import AccommodationCard from '@/components/accommodation/accommodation-card';
import CategoryList from '@/components/category/category-list';
import SearchForm from '@/components/search/search-form';
import { SearchCriteria } from '@/types';
export default async function SearchPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const location = searchParams.location as string;
const checkInDate = searchParams.checkInDate ? new Date(searchParams.checkInDate as string) : undefined;
const checkOutDate = searchParams.checkOutDate ? new Date(searchParams.checkOutDate as string) : undefined;
const guestCount = searchParams.guestCount ? parseInt(searchParams.guestCount as string) : undefined;
const categoryId = searchParams.categoryId ? parseInt(searchParams.categoryId as string) : undefined;
const searchCriteria: SearchCriteria = {
location,
checkInDate,
checkOutDate,
guestCount,
categoryId
};
const [categories, searchResults] = await Promise.all([
getCategories(),
searchAccommodations(searchCriteria)
]);
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-center mb-8">
<SearchForm initialValues={searchCriteria} />
</div>
<CategoryList
categories={categories}
selectedCategoryId={categoryId}
/>
<div className="flex justify-between items-center mt-8 mb-4">
<button className="px-4 py-2 border rounded-full flex items-center gap-2">
<span>フィルター</span>
</button>
<div className="flex items-center gap-2">
<span>合計金額(税抜き)を表示</span>
<div className="relative inline-block w-10 h-6 transition duration-200 ease-in-out rounded-full">
<input type="checkbox" className="absolute w-6 h-6 rounded-full bg-white border-2 appearance-none cursor-pointer peer checked:right-0 checked:bg-blue-500" />
<span className="block w-full h-full rounded-full bg-gray-300 peer-checked:bg-blue-300"></span>
</div>
</div>
</div>
<section>
{searchResults.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500">検索条件に一致する宿泊施設が見つかりませんでした。</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{searchResults.map((accommodation) => (
<AccommodationCard
key={accommodation.id}
accommodation={accommodation}
/>
))}
</div>
)}
</section>
</div>
);
}
6. コンポーネント
// src/components/layout/header.tsx
import Image from 'next/image';
import Link from 'next/link';
import { FaGlobe, FaBars, FaUserCircle } from 'react-icons/fa';
export default function Header() {
return (
<header className="border-b border-gray
**user**: 続けて
**claude-api**: ```typescript
// src/components/layout/header.tsx (続き)
export default function Header() {
return (
<header className="border-b border-gray-200 py-4">
<div className="container mx-auto px-4 flex justify-between items-center">
<Link href="/" className="flex items-center">
<Image
src="/images/logo.png"
alt="Airbnb"
width={102}
height={32}
className="h-8 w-auto"
/>
</Link>
<div className="hidden md:flex items-center space-x-4">
<Link href="/" className="font-medium text-sm">宿泊</Link>
<Link href="/experiences" className="font-medium text-sm">体験</Link>
</div>
<div className="flex items-center space-x-4">
<button className="hidden md:flex items-center text-sm font-medium">
ホストモードへ
</button>
<button className="p-2 rounded-full hover:bg-gray-100">
<FaGlobe className="h-5 w-5" />
</button>
<div className="flex items-center border rounded-full p-1 shadow-sm hover:shadow-md">
<FaBars className="h-4 w-4 mx-2" />
<FaUserCircle className="h-8 w-8 text-gray-500" />
</div>
</div>
</div>
</header>
);
}
// src/components/search/search-form.tsx
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
import { FaSearch } from 'react-icons/fa';
import { SearchCriteria } from '@/types';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';
interface SearchFormProps {
initialValues?: SearchCriteria;
}
export default function SearchForm({ initialValues }: SearchFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [location, setLocation] = useState(initialValues?.location || '');
const [checkInDate, setCheckInDate] = useState<Date | undefined>(initialValues?.checkInDate);
const [checkOutDate, setCheckOutDate] = useState<Date | undefined>(initialValues?.checkOutDate);
const [guestCount, setGuestCount] = useState(initialValues?.guestCount || 1);
useEffect(() => {
if (initialValues) {
setLocation(initialValues.location || '');
setCheckInDate(initialValues.checkInDate);
setCheckOutDate(initialValues.checkOutDate);
setGuestCount(initialValues.guestCount || 1);
}
}, [initialValues]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (location) params.set('location', location);
if (checkInDate) params.set('checkInDate', checkInDate.toISOString());
if (checkOutDate) params.set('checkOutDate', checkOutDate.toISOString());
if (guestCount) params.set('guestCount', guestCount.toString());
const categoryId = searchParams.get('categoryId');
if (categoryId) params.set('categoryId', categoryId);
router.push(`/search?${params.toString()}`);
};
return (
<div className="w-full max-w-4xl bg-white rounded-full shadow-md">
<form onSubmit={handleSubmit} className="flex items-center">
<div className="flex-1 px-6 py-3 border-r border-gray-200">
<label className="block text-xs font-bold">ロケーション</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="目的地を検索"
className="w-full outline-none text-sm"
/>
</div>
<div className="flex-1 px-6 py-3 border-r border-gray-200">
<label className="block text-xs font-bold">チェックイン</label>
<input
type="date"
value={checkInDate ? format(checkInDate, 'yyyy-MM-dd') : ''}
onChange={(e) => setCheckInDate(e.target.value ? new Date(e.target.value) : undefined)}
className="w-full outline-none text-sm"
/>
</div>
<div className="flex-1 px-6 py-3 border-r border-gray-200">
<label className="block text-xs font-bold">チェックアウト</label>
<input
type="date"
value={checkOutDate ? format(checkOutDate, 'yyyy-MM-dd') : ''}
onChange={(e) => setCheckOutDate(e.target.value ? new Date(e.target.value) : undefined)}
className="w-full outline-none text-sm"
/>
</div>
<div className="flex-1 px-6 py-3">
<label className="block text-xs font-bold">旅行者</label>
<input
type="number"
min="1"
value={guestCount}
onChange={(e) => setGuestCount(parseInt(e.target.value))}
className="w-full outline-none text-sm"
/>
</div>
<button
type="submit"
className="bg-rose-500 text-white p-4 rounded-full mx-2 hover:bg-rose-600 transition"
>
<FaSearch className="h-5 w-5" />
</button>
</form>
</div>
);
}
// src/components/category/category-list.tsx
'use client';
import { Category } from '@/types';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
interface CategoryListProps {
categories: Category[];
selectedCategoryId?: number;
}
export default function CategoryList({ categories, selectedCategoryId }: CategoryListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);
return params.toString();
},
[searchParams]
);
const handleCategoryClick = (categoryId: number) => {
if (selectedCategoryId === categoryId) {
// 選択解除
const params = new URLSearchParams(searchParams.toString());
params.delete('categoryId');
router.push(`${window.location.pathname}?${params.toString()}`);
} else {
// カテゴリー選択
router.push(`${window.location.pathname}?${createQueryString('categoryId', categoryId.toString())}`);
}
};
return (
<div className="flex overflow-x-auto space-x-8 py-4 border-b border-gray-200 no-scrollbar">
{categories.map((category) => (
<div
key={category.id}
className={`flex flex-col items-center cursor-pointer min-w-[70px] pb-2 ${
selectedCategoryId === category.id
? 'opacity-100 border-b-2 border-black'
: 'opacity-60 hover:opacity-100'
}`}
onClick={() => handleCategoryClick(category.id)}
>
<div className="w-6 h-6 mb-2">
<Image
src={category.iconUrl}
alt={category.name}
width={24}
height={24}
/>
</div>
<span className="text-xs whitespace-nowrap">{category.name}</span>
</div>
))}
</div>
);
}
// src/components/accommodation/accommodation-card.tsx
'use client';
import { Accommodation } from '@/types';
import Image from 'next/image';
import { useState } from 'react';
import { FaHeart, FaRegHeart } from 'react-icons/fa';
import { format } from 'date-fns';
import { ja } from 'date-fns/locale';
interface AccommodationCardProps {
accommodation: Accommodation;
}
export default function AccommodationCard({ accommodation }: AccommodationCardProps) {
const [isFavorite, setIsFavorite] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const toggleFavorite = (e: React.MouseEvent) => {
e.preventDefault();
setIsFavorite(!isFavorite);
};
const formatDateRange = () => {
const from = format(new Date(accommodation.availableDateFrom), 'M月d日', { locale: ja });
const to = format(new Date(accommodation.availableDateTo), 'd日', { locale: ja });
return `${from}〜${to}`;
};
return (
<div className="group">
<div className="relative aspect-square rounded-xl overflow-hidden mb-2">
<Image
src={accommodation.imageUrls[currentImageIndex]}
alt={accommodation.name}
fill
className="object-cover"
/>
<button
onClick={toggleFavorite}
className="absolute top-3 right-3 z-10"
>
{isFavorite ? (
<FaHeart className="h-6 w-6 text-rose-500" />
) : (
<FaRegHeart className="h-6 w-6 text-white drop-shadow-md" />
)}
</button>
{accommodation.guestChoice && (
<div className="absolute top-3 left-3 bg-white/80 px-2 py-1 rounded text-xs font-medium">
ゲストチョイス
</div>
)}
</div>
<div>
<div className="flex justify-between items-start">
<h3 className="font-medium text-sm">
{accommodation.prefecture}, {accommodation.city}
</h3>
<div className="flex items-center">
<span className="mr-1">★</span>
<span>{accommodation.rating.toFixed(2)}</span>
</div>
</div>
<p className="text-gray-500 text-sm">{accommodation.name}</p>
<p className="text-gray-500 text-sm">{formatDateRange()}</p>
<p className="mt-1">
<span className="font-semibold">
¥{accommodation.pricePerNight.toLocaleString()}
</span>
<span className="text-sm"> /泊</span>
</p>
</div>
</div>
);
}
7. サンプルデータ作成スクリプト
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import { addDays } from 'date-fns';
const prisma = new PrismaClient();
async function main() {
// カテゴリーの作成
const categories = [
{ name: '自然の中の家', iconUrl: '/images/categories/cabin.png' },
{ name: 'ログハウス', iconUrl: '/images/categories/loghouse.png' },
{ name: 'ビーチフロント', iconUrl: '/images/categories/beach.png' },
{ name: 'アイコニック', iconUrl: '/images/categories/iconic.png' },
{ name: 'ゴルフ', iconUrl: '/images/categories/golf.png' },
{ name: '豪華なプール', iconUrl: '/images/categories/pool.png' },
{ name: '大邸宅・城館', iconUrl: '/images/categories/castle.png' },
{ name: 'ツリーハウス', iconUrl: '/images/categories/treehouse.png' }
];
for (const category of categories) {
await prisma.category.upsert({
where: { name: category.name },
update: {},
create: category
});
}
// カテゴリーの取得
const cabinCategory = await prisma.category.findUnique({ where: { name: '自然の中の家' } });
const beachCategory = await prisma.category.findUnique({ where: { name: 'ビーチフロント' } });
const iconicCategory = await prisma.category.findUnique({ where: { name: 'アイコニック' } });
const poolCategory = await prisma.category.findUnique({ where: { name: '豪華なプール' } });
// 宿泊施設の作成
const accommodations = [
{
name: '山の眺望',
description: 'とてもセンスの良い日本家屋です。',
prefecture: '日本、愛知県',
city: '三ヶ根町',
imageUrls: ['/images/accommodations/mountain1.jpg'],
pricePerNight: 57765,
rating: 5.0,
reviewCount: 28,
guestChoice: true,
availableDateFrom: addDays(new Date(), 30), // 3月2日相当
availableDateTo: addDays(new Date(), 35), // 3月7日相当
categoryId: cabinCategory!.id
},
{
name: '大海原と海の眺望',
description: '夕陽が絶景の海辺の宿です。',
prefecture: '日本、長野県',
city: '那須烏山市',
imageUrls: ['/images/accommodations/ocean1.jpg'],
pricePerNight: 72192,
rating: 4.93,
reviewCount: 42,
guestChoice: true,
availableDateFrom: addDays(new Date(), 40), // 3月12日相当
availableDateTo: addDays(new Date(), 45), // 3月17日相当
categoryId: beachCategory!.id
},
{
name: '山の眺望',
description: '必要なものはすべて揃っています。',
prefecture: '日本、富士吉田市',
city: '山の麓',
imageUrls: ['/images/accommodations/mountain2.jpg'],
pricePerNight: 28800,
rating: 5.0,
reviewCount: 15,
guestChoice: true,
availableDateFrom: addDays(new Date(), 98), // 5月8日相当
availableDateTo: addDays(new Date(), 103), // 5月13日相当
categoryId: iconicCategory!.id
},
{
name: '海の眺望',
description: 'プライベートプール付きの豪華な宿です。',
prefecture: '日本、千葉市',
city: '海の
**user**: 続きを書いて
**claude-api**: ```typescript
// prisma/seed.ts (続き)
name: '海の眺望',
description: 'プライベートプール付きの豪華な宿です。',
prefecture: '日本、千葉市',
city: '海の近く',
imageUrls: ['/images/accommodations/pool1.jpg'],
pricePerNight: 99000,
rating: 4.86,
reviewCount: 35,
guestChoice: true,
availableDateFrom: addDays(new Date(), 30), // 3月2日相当
availableDateTo: addDays(new Date(), 35), // 3月7日相当
categoryId: poolCategory!.id
}
];
for (const accommodation of accommodations) {
await prisma.accommodation.create({
data: accommodation
});
}
// さらに多くのサンプルデータを追加
const prefectures = ['北海道', '東京都', '京都府', '大阪府', '沖縄県'];
const cities = ['札幌市', '新宿区', '京都市', '大阪市', '那覇市'];
const descriptions = [
'自然に囲まれた静かな宿です。',
'都会の喧騒から離れてリラックスできます。',
'伝統的な日本家屋で特別な体験を。',
'モダンな設備が整った快適な宿泊施設。',
'絶景を望む贅沢なひとときを。'
];
const allCategories = await prisma.category.findMany();
// 追加で20件のサンプルデータを作成
for (let i = 0; i < 20; i++) {
const randomPrefectureIndex = Math.floor(Math.random() * prefectures.length);
const randomCityIndex = Math.floor(Math.random() * cities.length);
const randomDescriptionIndex = Math.floor(Math.random() * descriptions.length);
const randomCategoryIndex = Math.floor(Math.random() * allCategories.length);
const price = Math.floor(Math.random() * 100000) + 10000;
const rating = (Math.random() * 1) + 4; // 4.0〜5.0の間
const reviewCount = Math.floor(Math.random() * 50) + 5;
const isGuestChoice = Math.random() > 0.7; // 30%の確率でゲストチョイス
const startDate = addDays(new Date(), Math.floor(Math.random() * 60) + 30);
const endDate = addDays(startDate, Math.floor(Math.random() * 10) + 5);
const imageNumber = (i % 4) + 1; // 1〜4の画像を循環使用
await prisma.accommodation.create({
data: {
name: `${prefectures[randomPrefectureIndex]}の宿 ${i + 5}`,
description: descriptions[randomDescriptionIndex],
prefecture: prefectures[randomPrefectureIndex],
city: cities[randomCityIndex],
imageUrls: [`/images/accommodations/sample${imageNumber}.jpg`],
pricePerNight: price,
rating: parseFloat(rating.toFixed(2)),
reviewCount: reviewCount,
guestChoice: isGuestChoice,
availableDateFrom: startDate,
availableDateTo: endDate,
categoryId: allCategories[randomCategoryIndex].id
}
});
}
console.log('Seed data created successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
8. package.json に seed スクリプトを追加
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"db:seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
9. API ルート
// src/app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const location = searchParams.get('location');
const checkInDate = searchParams.get('checkInDate') ? new Date(searchParams.get('checkInDate')!) : null;
const checkOutDate = searchParams.get('checkOutDate') ? new Date(searchParams.get('checkOutDate')!) : null;
const categoryId = searchParams.get('categoryId') ? parseInt(searchParams.get('categoryId')!) : null;
const whereClause: any = {};
if (location) {
whereClause.OR = [
{ prefecture: { contains: location, mode: 'insensitive' } },
{ city: { contains: location, mode: 'insensitive' } }
];
}
if (checkInDate && checkOutDate) {
whereClause.AND = [
{ availableDateFrom: { lte: checkOutDate } },
{ availableDateTo: { gte: checkInDate } }
];
}
if (categoryId) {
whereClause.categoryId = categoryId;
}
const accommodations = await prisma.accommodation.findMany({
where: whereClause,
include: {
category: true
},
orderBy: {
rating: 'desc'
}
});
return NextResponse.json(accommodations);
} catch (error) {
console.error('Search API error:', error);
return NextResponse.json(
{ error: 'Failed to search accommodations' },
{ status: 500 }
);
}
}
10. グローバルCSS
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
/* スクロールバーを非表示にするユーティリティクラス */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
環境構築と実行手順
1. プロジェクトのセットアップ
# プロジェクトの作成
npx create-next-app@latest airbnb-clone --typescript --tailwind --app
cd airbnb-clone
# 必要なパッケージのインストール
npm install shadcn-ui @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-popover @radix-ui/react-toggle @radix-ui/react-checkbox @radix-ui/react-select @radix-ui/react-tabs class-variance-authority clsx tailwind-merge lucide-react
npm install prisma @prisma/client
npm install @supabase/supabase-js
npm install date-fns react-day-picker react-icons
npm install ts-node --save-dev
# Shadcn UIのセットアップ
npx shadcn-ui@latest init
2. Supabaseのセットアップ
- Supabaseにアクセスし、アカウントを作成
- 新しいプロジェクトを作成
- プロジェクトのURLとAPIキーを取得
-
.env
ファイルを作成し、以下の環境変数を設定:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-url.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DATABASE_URL=postgresql://postgres:password@db.your-project-url.supabase.co:5432/postgres
3. Prismaのセットアップ
# Prismaの初期化
npx prisma init
# スキーマを編集後、マイグレーションを実行
npx prisma migrate dev --name init
# サンプルデータの作成
npm run db:seed
4. 画像の準備
-
public/images/categories/
ディレクトリを作成し、カテゴリーアイコンを配置 -
public/images/accommodations/
ディレクトリを作成し、宿泊施設の画像を配置
5. アプリケーションの実行
npm run dev
ブラウザで http://localhost:3000 にアクセスして、アプリケーションを確認できます。
実装のポイント
-
サーバーコンポーネントとクライアントコンポーネント:
- サーバーコンポーネント: ページコンポーネント、データ取得ロジック
- クライアントコンポーネント: インタラクティブな要素(検索フォーム、カテゴリーリスト、お気に入りボタン)
-
サーバーアクション:
- 検索機能やカテゴリーフィルタリングにサーバーアクションを活用
- フォームの送信やデータ取得を効率化
-
Prismaとデータベース:
- 型安全なデータベースアクセス
- リレーショナルデータモデル(カテゴリーと宿泊施設の関連付け)
-
Supabase統合:
- PostgreSQLデータベースの利用
- 将来的に認証や画像ストレージの拡張が容易
-
Shadcn UI:
- 高品質なUIコンポーネントを活用
- カスタマイズ性の高いデザインシステム
-
レスポンシブデザイン:
- モバイルからデスクトップまで対応したレイアウト
- Tailwind CSSによる効率的なスタイリング
拡張ポイント
-
認証機能:
- Supabaseの認証機能を活用してユーザー登録・ログイン機能を追加
-
予約システム:
- 宿泊施設の予約機能の実装
- カレンダーUIの追加
-
詳細ページ:
- 宿泊施設の詳細情報を表示するページの追加
- 画像ギャラリーの実装
-
レビューシステム:
- ユーザーがレビューを投稿できる機能
- 評価の集計と表示
-
地図統合:
- Google MapsやMapboxを使用して宿泊施設の位置を表示
-
多言語対応:
- next-intlなどを使用した多言語サポート
-
支払い統合:
- StripeなどのAPIを使用した支払い処理の実装
この実装により、画像のAirbnb風の宿泊検索サイトを最新のNext.js 14とApp Router、Shadcn UI、Supabase、Prismaを使って構築できます。サーバーコンポーネントとクライアントコンポーネントを適切に分離し、サーバーアクションを活用することで、パフォーマンスと開発効率を両立させています。