mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:31:13 -05:00
606 lines
12 KiB
Markdown
606 lines
12 KiB
Markdown
# Next.js 15 Migration Guide
|
|
|
|
**For:** ThrillWiki React → Next.js 15 + App Router Migration
|
|
**Last Updated:** November 9, 2025
|
|
|
|
---
|
|
|
|
## 🎯 Overview
|
|
|
|
This guide provides patterns, examples, and best practices for migrating from React SPA to Next.js 15 with App Router.
|
|
|
|
**What's Changing:**
|
|
- React Router → Next.js File-based Routing
|
|
- Client-side rendering → Server-side rendering by default
|
|
- Manual data fetching → Built-in data fetching
|
|
- Vite → Turbopack
|
|
- npm → Bun
|
|
|
|
---
|
|
|
|
## 📁 File Structure Migration
|
|
|
|
### Old Structure (React SPA)
|
|
|
|
```
|
|
src/
|
|
├── pages/
|
|
│ ├── Index.tsx
|
|
│ ├── Parks.tsx
|
|
│ ├── ParkDetail.tsx
|
|
│ └── ...
|
|
├── components/
|
|
│ ├── Navigation.tsx
|
|
│ ├── ParkCard.tsx
|
|
│ └── ...
|
|
├── hooks/
|
|
├── services/
|
|
└── App.tsx
|
|
```
|
|
|
|
### New Structure (Next.js 15)
|
|
|
|
```
|
|
app/
|
|
├── layout.tsx # Root layout (replaces App.tsx)
|
|
├── page.tsx # Homepage (replaces Index.tsx)
|
|
├── parks/
|
|
│ ├── page.tsx # /parks (replaces Parks.tsx)
|
|
│ └── [parkSlug]/
|
|
│ └── page.tsx # /parks/[slug] (replaces ParkDetail.tsx)
|
|
components/
|
|
├── navigation/
|
|
│ └── Navigation.tsx # Shared components
|
|
├── parks/
|
|
│ └── ParkCard.tsx
|
|
hooks/
|
|
services/
|
|
lib/
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 Routing Migration
|
|
|
|
### React Router → Next.js
|
|
|
|
#### Old: React Router
|
|
|
|
```typescript
|
|
// App.tsx
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
|
|
function App() {
|
|
return (
|
|
<BrowserRouter>
|
|
<Routes>
|
|
<Route path="/" element={<HomePage />} />
|
|
<Route path="/parks" element={<ParksPage />} />
|
|
<Route path="/parks/:slug" element={<ParkDetailPage />} />
|
|
</Routes>
|
|
</BrowserRouter>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### New: Next.js File-based Routing
|
|
|
|
```
|
|
app/
|
|
├── page.tsx → /
|
|
├── parks/
|
|
│ ├── page.tsx → /parks
|
|
│ └── [slug]/
|
|
│ └── page.tsx → /parks/:slug
|
|
```
|
|
|
|
### Navigation
|
|
|
|
#### Old: React Router Links
|
|
|
|
```typescript
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
|
|
function Navigation() {
|
|
const navigate = useNavigate();
|
|
|
|
return (
|
|
<nav>
|
|
<Link to="/parks">Parks</Link>
|
|
<button onClick={() => navigate('/parks/cedar-point')}>
|
|
Cedar Point
|
|
</button>
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### New: Next.js Links
|
|
|
|
```typescript
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
function Navigation() {
|
|
const router = useRouter();
|
|
|
|
return (
|
|
<nav>
|
|
<Link href="/parks">Parks</Link>
|
|
<button onClick={() => router.push('/parks/cedar-point')}>
|
|
Cedar Point
|
|
</button>
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
### URL Parameters
|
|
|
|
#### Old: React Router
|
|
|
|
```typescript
|
|
import { useParams } from 'react-router-dom';
|
|
|
|
function ParkDetail() {
|
|
const { slug } = useParams();
|
|
// ...
|
|
}
|
|
```
|
|
|
|
#### New: Next.js (Server Component)
|
|
|
|
```typescript
|
|
// app/parks/[slug]/page.tsx
|
|
export default function ParkDetail({
|
|
params
|
|
}: {
|
|
params: { slug: string }
|
|
}) {
|
|
// params.slug is available
|
|
}
|
|
```
|
|
|
|
#### New: Next.js (Client Component)
|
|
|
|
```typescript
|
|
'use client';
|
|
import { useParams } from 'next/navigation';
|
|
|
|
export default function ParkDetail() {
|
|
const params = useParams();
|
|
const slug = params.slug;
|
|
}
|
|
```
|
|
|
|
### Query Parameters
|
|
|
|
#### Old: React Router
|
|
|
|
```typescript
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
function ParksPage() {
|
|
const [searchParams] = useSearchParams();
|
|
const filter = searchParams.get('filter');
|
|
}
|
|
```
|
|
|
|
#### New: Next.js (Server Component)
|
|
|
|
```typescript
|
|
export default function ParksPage({
|
|
searchParams
|
|
}: {
|
|
searchParams: { filter?: string }
|
|
}) {
|
|
const filter = searchParams.filter;
|
|
}
|
|
```
|
|
|
|
#### New: Next.js (Client Component)
|
|
|
|
```typescript
|
|
'use client';
|
|
import { useSearchParams } from 'next/navigation';
|
|
|
|
export default function ParksPage() {
|
|
const searchParams = useSearchParams();
|
|
const filter = searchParams.get('filter');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🖥️ Server vs Client Components
|
|
|
|
### Decision Tree
|
|
|
|
```
|
|
Does it need interactivity?
|
|
├─ NO → Server Component (default)
|
|
│ ├─ Displays data
|
|
│ ├─ Static content
|
|
│ └─ SEO-critical
|
|
│
|
|
└─ YES → Client Component ('use client')
|
|
├─ Forms with state
|
|
├─ Event handlers
|
|
├─ Browser APIs
|
|
└─ Hooks (useState, useEffect, etc.)
|
|
```
|
|
|
|
### Server Component Example
|
|
|
|
```typescript
|
|
// app/parks/page.tsx
|
|
// No 'use client' directive = Server Component
|
|
|
|
import { env } from '@/lib/env';
|
|
|
|
export default async function ParksPage() {
|
|
// Fetch data directly in component
|
|
const parks = await fetch(
|
|
`${env.NEXT_PUBLIC_DJANGO_API_URL}/parks/`
|
|
).then(r => r.json());
|
|
|
|
return (
|
|
<div>
|
|
<h1>Theme Parks</h1>
|
|
{parks.results.map(park => (
|
|
<ParkCard key={park.id} park={park} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Client Component Example
|
|
|
|
```typescript
|
|
// components/parks/ParkFilters.tsx
|
|
'use client'; // Mark as Client Component
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
export function ParkFilters() {
|
|
const [filter, setFilter] = useState('');
|
|
const router = useRouter();
|
|
|
|
const handleFilterChange = (value: string) => {
|
|
setFilter(value);
|
|
router.push(`/parks?filter=${value}`);
|
|
};
|
|
|
|
return (
|
|
<select value={filter} onChange={(e) => handleFilterChange(e.target.value)}>
|
|
<option value="">All Parks</option>
|
|
<option value="theme">Theme Parks</option>
|
|
<option value="water">Water Parks</option>
|
|
</select>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Mixing Server and Client
|
|
|
|
```typescript
|
|
// app/parks/page.tsx (Server Component)
|
|
import { ParkFilters } from '@/components/parks/ParkFilters'; // Client
|
|
import { ParksList } from '@/components/parks/ParksList'; // Can be Server
|
|
|
|
export default async function ParksPage() {
|
|
const parks = await fetch(API_URL).then(r => r.json());
|
|
|
|
return (
|
|
<div>
|
|
<h1>Parks</h1>
|
|
<ParkFilters /> {/* Client Component for interactivity */}
|
|
<ParksList parks={parks} /> {/* Server Component for display */}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📡 Data Fetching
|
|
|
|
### Old: React with useEffect
|
|
|
|
```typescript
|
|
function ParkDetail({ slug }: { slug: string }) {
|
|
const [park, setPark] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/parks/${slug}`)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setPark(data);
|
|
setLoading(false);
|
|
});
|
|
}, [slug]);
|
|
|
|
if (loading) return <div>Loading...</div>;
|
|
return <div>{park.name}</div>;
|
|
}
|
|
```
|
|
|
|
### New: Next.js Server Component
|
|
|
|
```typescript
|
|
// app/parks/[slug]/page.tsx
|
|
export default async function ParkDetail({
|
|
params
|
|
}: {
|
|
params: { slug: string }
|
|
}) {
|
|
// Fetch directly - no useEffect needed
|
|
const park = await fetch(
|
|
`${API_URL}/parks/${params.slug}/`,
|
|
{ next: { revalidate: 3600 } } // Cache for 1 hour
|
|
).then(r => r.json());
|
|
|
|
return <div>{park.name}</div>;
|
|
}
|
|
```
|
|
|
|
### Caching Strategies
|
|
|
|
```typescript
|
|
// No caching (always fresh)
|
|
fetch(url, { cache: 'no-store' });
|
|
|
|
// Cache forever (until revalidated)
|
|
fetch(url, { cache: 'force-cache' });
|
|
|
|
// Revalidate after 60 seconds
|
|
fetch(url, { next: { revalidate: 60 } });
|
|
|
|
// Revalidate on-demand (from API route)
|
|
fetch(url, { next: { tags: ['parks'] } });
|
|
// Then: revalidateTag('parks')
|
|
```
|
|
|
|
### Parallel Data Fetching
|
|
|
|
```typescript
|
|
export default async function ParkDetail({ params }) {
|
|
// Fetch in parallel
|
|
const [park, rides, reviews] = await Promise.all([
|
|
fetch(`${API_URL}/parks/${params.slug}/`),
|
|
fetch(`${API_URL}/parks/${params.slug}/rides/`),
|
|
fetch(`${API_URL}/parks/${params.slug}/reviews/`),
|
|
]).then(responses =>
|
|
Promise.all(responses.map(r => r.json()))
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<ParkHeader park={park} />
|
|
<RidesList rides={rides} />
|
|
<ReviewsList reviews={reviews} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Layouts
|
|
|
|
### Root Layout
|
|
|
|
```typescript
|
|
// app/layout.tsx
|
|
import { Inter } from 'next/font/google';
|
|
import './globals.css';
|
|
|
|
const inter = Inter({ subsets: ['latin'] });
|
|
|
|
export const metadata = {
|
|
title: 'ThrillWiki',
|
|
description: 'Theme Park Database',
|
|
};
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<html lang="en">
|
|
<body className={inter.className}>
|
|
<Navigation />
|
|
<main>{children}</main>
|
|
<Footer />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Nested Layouts
|
|
|
|
```typescript
|
|
// app/parks/layout.tsx
|
|
export default function ParksLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="parks-container">
|
|
<ParksSidebar />
|
|
<div className="parks-content">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔍 SEO & Metadata
|
|
|
|
### Static Metadata
|
|
|
|
```typescript
|
|
// app/parks/page.tsx
|
|
export const metadata = {
|
|
title: 'Theme Parks - ThrillWiki',
|
|
description: 'Browse theme parks from around the world',
|
|
openGraph: {
|
|
title: 'Theme Parks',
|
|
description: 'Browse theme parks',
|
|
images: ['/og-parks.png'],
|
|
},
|
|
};
|
|
```
|
|
|
|
### Dynamic Metadata
|
|
|
|
```typescript
|
|
// app/parks/[slug]/page.tsx
|
|
export async function generateMetadata({ params }) {
|
|
const park = await fetch(`${API_URL}/parks/${params.slug}/`)
|
|
.then(r => r.json());
|
|
|
|
return {
|
|
title: `${park.name} - ThrillWiki`,
|
|
description: park.description,
|
|
openGraph: {
|
|
title: park.name,
|
|
description: park.description,
|
|
images: [park.image_url],
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ⚡ Loading & Error States
|
|
|
|
### Loading UI
|
|
|
|
```typescript
|
|
// app/parks/loading.tsx
|
|
export default function Loading() {
|
|
return (
|
|
<div className="skeleton">
|
|
<div className="skeleton-header" />
|
|
<div className="skeleton-grid">
|
|
{[...Array(6)].map((_, i) => (
|
|
<div key={i} className="skeleton-card" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Error Boundaries
|
|
|
|
```typescript
|
|
// app/parks/error.tsx
|
|
'use client';
|
|
|
|
export default function Error({
|
|
error,
|
|
reset,
|
|
}: {
|
|
error: Error;
|
|
reset: () => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<h2>Something went wrong!</h2>
|
|
<button onClick={reset}>Try again</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Not Found
|
|
|
|
```typescript
|
|
// app/parks/[slug]/not-found.tsx
|
|
export default function NotFound() {
|
|
return (
|
|
<div>
|
|
<h2>Park Not Found</h2>
|
|
<p>The park you're looking for doesn't exist.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// In page.tsx
|
|
import { notFound } from 'next/navigation';
|
|
|
|
export default async function ParkDetail({ params }) {
|
|
const park = await fetch(`${API_URL}/parks/${params.slug}/`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.catch(() => notFound());
|
|
|
|
return <div>{park.name}</div>;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 Deployment
|
|
|
|
### Build Command
|
|
|
|
```bash
|
|
bun run build
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
Set in Vercel/hosting platform:
|
|
- `NEXT_PUBLIC_DJANGO_API_URL`
|
|
- `NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID`
|
|
- Server-only secrets (no NEXT_PUBLIC_ prefix)
|
|
|
|
### Vercel Deployment
|
|
|
|
```bash
|
|
# Install Vercel CLI
|
|
bun add -g vercel
|
|
|
|
# Deploy
|
|
vercel
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Additional Resources
|
|
|
|
- Next.js 15 Documentation: https://nextjs.org/docs
|
|
- App Router: https://nextjs.org/docs/app
|
|
- Server Components: https://nextjs.org/docs/app/building-your-application/rendering/server-components
|
|
- Data Fetching: https://nextjs.org/docs/app/building-your-application/data-fetching
|
|
|
|
---
|
|
|
|
## 🔗 Related Documentation
|
|
|
|
- [Phase 1: Foundation](./PHASE_01_FOUNDATION.md)
|
|
- [Phase 12: Pages Migration](./PHASE_12_PAGES_MIGRATION.md)
|
|
- [Phase 13: Optimization](./PHASE_13_NEXTJS_OPTIMIZATION.md)
|
|
- [Environment Variables](./ENVIRONMENT_VARIABLES.md)
|
|
- [Bun Guide](./BUN_GUIDE.md)
|
|
|
|
---
|
|
|
|
**Document Version:** 1.0
|
|
**Last Updated:** November 9, 2025
|