Week 11

App Router

Data Fetching and Mutations

Migrating from React Router

Going to production

Rendering Strategies

Practice

Assignment

Front end Track

Why Migrate?

A Vite + React Router app sends a mostly empty HTML file to the browser. The browser downloads JavaScript, runs it, then fetches data - so the user sees a blank page or a loading spinner until all of that completes. Search engine crawlers (like Google) face the same problem: they may index nothing if they don't wait for JavaScript to run.

Next.js moves data fetching to the server. By the time HTML reaches the browser, the data is already in it. The user sees real content immediately, and crawlers index the full page. You also eliminate the useEffect + useState + loading state pattern for initial data - the component just await what it needs and renders.

The migration is mostly additive: existing Client Components move over almost unchanged. The main work is reorganising the file structure and converting data fetching from useEffect to async Server Components.

Mapping Routes

React Router setup

// src/App.tsx (Vite + React Router)
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { HomePage } from './pages/HomePage'
import { ProductsPage } from './pages/ProductsPage'
import { ProductDetailPage } from './pages/ProductDetailPage'

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products" element={<ProductsPage />} />
        <Route path="/products/:id" element={<ProductDetailPage />} />
      </Routes>
    </BrowserRouter>
  )
}

Equivalent Next.js App Router structure

app/
├── layout.tsx              ← shared wrapper (was the outer App component)
├── page.tsx                ← was HomePage
├── products/
│   ├── page.tsx            ← was ProductsPage
│   └── [id]/
│       └── page.tsx        ← was ProductDetailPage

The <Route> declarations disappear. The file structure is the route configuration.

Moving Client Components Unchanged

Any component that uses React hooks or browser APIs works in Next.js by adding "use client" at the top. No other changes needed.

// With Next.js App Router
"use client" // this is the only change

import { useState } from 'react'

export function SearchBar() {
  const [query, setQuery] = useState('')
  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search..."
    />
  )
}

If you forget "use client" on a component that uses useState or useEffect, Next.js will throw a clear error at build time telling you which component needs the directive.

<aside> ⌨️

Hands on: Take a Client Component from a previous project (a counter, a search input, a toggle - anything with useState). Copy it into a Next.js project. Try rendering it without "use client" first and read the error. Then add the directive and confirm it works. This is the entire migration for most simple components.

</aside>

Converting Data Fetching to Server Components

This is where the real gain is. React Router apps typically fetch data inside useEffect. In Next.js, Server Components fetch directly.

Before (React Router)

// src/pages/ProductsPage.tsx
import { useState, useEffect } from 'react'

export function ProductsPage() {
  const [products, setProducts] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/products')
      .then(r => r.json())
      .then(data => {
        setProducts(data)
        setLoading(false)
      })
  }, [])

  if (loading) return <p>Loading...</p>

  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

After (Next.js Server Component)

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetch('<https://api.example.com/products>').then(r => r.json())

  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

useState, useEffect, the loading state, and the conditional render are all gone. The data is ready when the component renders because it ran on the server first.

<aside> ⌨️

Hands on: Pick one data-fetching component from your Week 6 portfolio project - the kind that uses useEffect to fetch and useState to store the result. Rewrite it as an async Server Component that awaits the fetch directly. Count the lines you deleted. Then add a loading.tsx next to it so users see a skeleton while the data loads.

</aside>

Changing Navigation

React Router

import { Link, useNavigate, useParams } from 'react-router-dom'

// In a component:
const navigate = useNavigate()
const { id } = useParams()
navigate('/products')

Next.js

import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'

// In a Client Component:
const router = useRouter()
const params = useParams()
router.push('/products')

The concepts are identical, just from a different import path. useRouter and useParams still require "use client".

For dynamic route params in Server Components, use the params prop instead of a hook:

// app/products/[id]/page.tsx
export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  // no useParams() needed
}

What Breaks and What Stays the Same

What stays the same

What changes

React Router Next.js App Router
Route config in App.tsx File system routing in app/
react-router-dom next/link, next/navigation
useNavigate() useRouter() from next/navigation
useParams() hook params prop in Server Components, useParams() hook in Client Components
Data fetching in useEffect await fetch(...) in async Server Components
index.html entry point app/layout.tsx root layout
npm run dev with Vite npm run dev with Next.js (same command, different server)
Static output (npm run builddist/) Server output (.next/) - needs a Node.js host

What breaks

import dynamic from 'next/dynamic'

// This component only loads in the browser - never on the server
const MapComponent = dynamic(() => import('@/components/Map'), { ssr: false })

export default function LocationPage() {
  return (
    <div>
      <h1>Find us</h1>
      <MapComponent lat={52.37} lng={4.89} />
    </div>
  )
}

<aside> ⚠️

The most common error when migrating is: "You're importing a component that needs X. It only works in a Client Component but none of its parents are marked with 'use client'." The fix is almost always to add "use client" to the component that uses the hook or browser API.

</aside>


Additional Resources

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.