Files
thrilltrack-explorer/migration/NEXTJS_15_MIGRATION_GUIDE.md

12 KiB

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

// 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

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>
  );
}
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

import { useParams } from 'react-router-dom';

function ParkDetail() {
  const { slug } = useParams();
  // ...
}

New: Next.js (Server Component)

// app/parks/[slug]/page.tsx
export default function ParkDetail({ 
  params 
}: { 
  params: { slug: string } 
}) {
  // params.slug is available
}

New: Next.js (Client Component)

'use client';
import { useParams } from 'next/navigation';

export default function ParkDetail() {
  const params = useParams();
  const slug = params.slug;
}

Query Parameters

Old: React Router

import { useSearchParams } from 'react-router-dom';

function ParksPage() {
  const [searchParams] = useSearchParams();
  const filter = searchParams.get('filter');
}

New: Next.js (Server Component)

export default function ParksPage({
  searchParams
}: {
  searchParams: { filter?: string }
}) {
  const filter = searchParams.filter;
}

New: Next.js (Client Component)

'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

// 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

// 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

// 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

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

// 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

// 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

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

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

# Install Vercel CLI
bun add -g vercel

# Deploy
vercel

📚 Additional Resources



Document Version: 1.0
Last Updated: November 9, 2025