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.
// 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>
)
}
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.
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>
This is where the real gain is. React Router apps typically fetch data inside useEffect. In Next.js, Server Components fetch directly.
// 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>
)
}
// 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>
import { Link, useNavigate, useParams } from 'react-router-dom'
// In a component:
const navigate = useNavigate()
const { id } = useParams()
navigate('/products')
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
}
useState, useReducer) - works in Client Componentsshadcn/ui, MUI, etc.) - add "use client" if needed| 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 build → dist/) |
Server output (.next/) - needs a Node.js host |
app/ files - code like window.localStorage will throw if it runs during server rendering. Move it inside a "use client" component or behind a typeof window !== 'undefined' check.<Outlet> - replaced by the {children} pattern in layout.tsxreact-router-dom imports - these don't exist in Next.js. Remove them and use next/link / next/navigation.window or document at import time, which doesn't exist on the server. Use next/dynamic with { ssr: false } to load them only in the browser: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>
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.