Web Development with React
Hands-onIntermediate 40 min· Building real apps · 16 of 21

Next.js: server rendering and when you need it

Understand client-side vs server rendering, then build a small Next.js app with file-based routes, server components that fetch data, a dynamic page and one interactive client component. Learn when Next.js is worth it and when plain React is enough.

What you'll build: a small recipe-categories site in Next.js using the App Router. It has a home page, a page for each category, and a like button that is interactive. Along the way you will learn what server rendering is and why it matters.

Prerequisites: the React core lessons (components, props, state, effects), React Router basics, and Node.js installed (a current LTS version). The data comes from TheMealDB, a free public recipe API, so you need an internet connection but no account.

The problem Next.js solves

A React app made with Vite is a single-page app (SPA). The server sends an almost empty HTML file (<div id="root"></div>) plus a JavaScript bundle. The browser downloads the JavaScript, runs it, and only then draws the page. This is client-side rendering.

That is fine for dashboards and tools behind a login. But for public pages it has costs: the user sees a blank screen until JavaScript loads (slow on a weak phone or a 4G network), and search engines and link previews (WhatsApp, LinkedIn) may see an empty page.

Server rendering means the server runs your React components and sends finished HTML. The user sees content immediately, and JavaScript then "wakes up" the page to make it interactive. Next.js is a React framework that gives you this, plus file-based routing, data fetching, image optimisation and an API layer, with sensible defaults.

Ways to render

ApproachHTML is madeGood for
Client-side (Vite SPA)In the browser, after JS loadsApps behind a login, dashboards, internal tools
Server-side rendering (SSR)On the server, for each requestPages that change per user or per request
Static generation (SSG)At build time, onceBlogs, docs, marketing pages that rarely change

Create the project

Set up

  1. 1Open a terminal in the folder where you keep projects.
  2. 2Run npx create-next-app@latest recipe-site. It asks questions. Choose No for TypeScript (we keep plain JavaScript), Yes for ESLint, No for Tailwind and the React Compiler, Yes for the src/ directory only if you like it (this lesson assumes No), Yes for App Router, and No to customising the import alias. Wording and defaults change between versions, so read each question.
  3. 3Run cd recipe-site and then npm run dev.
  4. 4Open http://localhost:3000. You should see the starter page. Edit app/page.js and save: the page updates without a refresh.
What you should see
Terminal: a message that Next.js is ready, with a Local address of http://localhost:3000.
Browser: the default Next.js welcome page. After you edit app/page.js, the browser shows your change within a second.

File-based routing

In React Router you wrote a list of routes in code. In Next.js the folders inside `app/` are the routes. A folder becomes a URL segment, and the file page.js inside it is the page for that URL. A file called layout.js wraps all pages beneath it.

- app/page.js is / - app/about/page.js is /about - app/category/[name]/page.js is /category/anything, where [name] is a variable part (a dynamic segment) - app/layout.js is the root layout shared by every page

app/layout.js
import Link from 'next/link';

export const metadata = {
  title: 'Recipe Site',
  description: 'Browse recipe categories',
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body style={{ fontFamily: 'sans-serif', margin: '0 auto', maxWidth: 720, padding: 16 }}>
        <nav style={{ marginBottom: 24 }}>
          <Link href="/">Home</Link>
        </nav>
        {children}
      </body>
    </html>
  );
}

Replace the starter layout with this. It keeps the required html and body tags, sets the page title for every page through the metadata export, and uses Link for navigation without a full page reload. If your starter imported a globals.css or fonts, you can delete those lines.

Server components fetch data directly

In the App Router, components are server components by default. They run only on the server, so they can be async, can await a fetch directly, and never ship their code to the browser. There is no useEffect, no loading useState: you just await the data and return JSX.

That makes the page below much shorter than its Vite equivalent.

app/page.js
import Link from 'next/link';

async function getCategories() {
  const res = await fetch('https://www.themealdb.com/api/json/v1/1/categories.php');
  if (!res.ok) throw new Error('Could not load categories');
  const data = await res.json();
  return data.categories;
}

export default async function HomePage() {
  const categories = await getCategories();

  return (
    <main>
      <h1>Recipe categories</h1>
      <ul>
        {categories.map((c) => (
          <li key={c.idCategory}>
            <Link href={`/category/${c.strCategory}`}>{c.strCategory}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Replace the starter page. The component is async and awaits the fetch on the server. View the page source in your browser (Ctrl+U): the category names are already in the HTML, which is what search engines and link previews see.

Compare with a Vite app

Open View Source on a Vite app and you see an empty <div id="root">. Do the same here and you see the full list. This one difference is the main reason teams choose Next.js for public sites.

A dynamic route

Create the file app/category/[name]/page.js. The square brackets are part of the folder name. The value in the URL arrives in the params prop. In recent versions of Next.js, params is a promise, so you await it. If your version gives you a plain object, await still works on it, so the code is safe either way.

app/category/[name]/page.js
import Link from 'next/link';
import LikeButton from './LikeButton';

async function getMeals(category) {
  const url = `https://www.themealdb.com/api/json/v1/1/filter.php?c=${encodeURIComponent(category)}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error('Could not load meals');
  const data = await res.json();
  // The API returns { meals: null } for an unknown category
  return data.meals ?? [];
}

export default async function CategoryPage({ params }) {
  const { name } = await params;
  const category = decodeURIComponent(name);
  const meals = await getMeals(category);

  return (
    <main>
      <h1>{category} recipes</h1>
      {meals.length === 0 && <p>No recipes found.</p>}
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {meals.slice(0, 8).map((m) => (
          <li key={m.idMeal} style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 12 }}>
            <img src={m.strMealThumb} alt={m.strMeal} width={64} height={64} />
            <span style={{ flex: 1 }}>{m.strMeal}</span>
            <LikeButton />
          </li>
        ))}
      </ul>
      <Link href="/">Back</Link>
    </main>
  );
}

Visit /category/Seafood after the next step. encodeURIComponent protects the URL when a name contains spaces or special characters. Using a plain img tag keeps the lesson short; next/image is the optimised alternative.

Client components for interactivity

Server components cannot use useState, useEffect or event handlers like onClick, because they never run in the browser. When you need those, create a client component by putting the line 'use client' at the very top of the file. Client components are still pre-rendered to HTML on the server, then hydrated in the browser.

The rule of thumb: keep as much as you can as server components, and make only the small interactive leaves client components.

app/category/[name]/LikeButton.js
'use client';

import { useState } from 'react';

export default function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)} aria-pressed={liked}>
      {liked ? 'Liked' : 'Like'}
    </button>
  );
}

Only this tiny file is sent to the browser as JavaScript. The list around it stays on the server. Click a button: it toggles without reloading the page.

Common mistakes

1. Using useState or onClick in a file without 'use client': Next.js shows an error telling you to add it. 2. Putting 'use client' at the top of a big page 'to make errors go away'. This throws away the benefit of server components. Move it into a small component instead. 3. Putting secrets (API keys) in a client component. Client code is visible to everyone. Keep keys in server components or server code and in environment variables without the NEXT_PUBLIC_ prefix.

Loading and error states

Because the server awaits the data, the user waits during that time. Next.js lets you show something meanwhile with special files next to page.js:

- loading.js is shown while the page's data loads. - error.js is shown if the page throws (it must be a client component). - not-found.js is shown when you call notFound().

app/category/[name]/loading.js and error.js
// app/category/[name]/loading.js
export default function Loading() {
  return <p>Loading recipes...</p>;
}

// app/category/[name]/error.js
'use client';

export default function ErrorPage({ error, reset }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

These are two separate files; the comment on the first line of each shows its path. Disconnect your internet and reload a category page to see error.js.

Caching and fresh data

Next.js is built to avoid unnecessary work, and caching behaviour has changed between major versions. In some versions fetch results were cached by default; in newer ones they are not unless you opt in. Do not rely on memory here: check the caching page of the Next.js documentation for the version you installed. You can always be explicit per request:

- fetch(url, { cache: 'no-store' }) always gets fresh data on each request. - fetch(url, { next: { revalidate: 3600 } }) reuses the result and refreshes it at most once an hour.

Do you need Next.js?

A practical decision guide

Your app is...Choose
A dashboard or tool behind a login, where SEO does not matterVite + React (plus React Router)
A public site where people arrive from Google or shared links (blog, e-commerce, portfolio, docs)Next.js
A site with mostly static contentNext.js static generation, or a simpler static site tool
You need a few backend endpoints next to the frontendNext.js route handlers can serve them, though a separate backend is fine for larger systems
You are learning React for the first timeVite first; add Next.js once components, state and effects feel comfortable

Next.js is popular in job listings, so knowing its basics is valuable. It is not always the right choice: it adds concepts (server vs client components, caching) and it needs a Node.js server or a host that supports it, while a Vite build is just static files you can host anywhere for free.

Try it: Extend the site

1. Add a page app/about/page.js and a link to it in the layout. Visit /about. 2. Export a fixed metadata object with a title from the category page, then check the browser tab title. 3. Make the category page also show the meal area. The filter.php endpoint does not return it, so look up lookup.php?i=<idMeal> for one meal and display its strArea on a new route app/meal/[id]/page.js. 4. Link each meal name to its meal page.

Prompt · Decide: Next.js or Vite?
I am building [describe your project: who uses it, public or behind a login, how often content changes, any need for search engine traffic]. My experience: [your React level]. Hosting budget: [free / small].

Recommend Next.js or Vite + React, with reasons specific to my project. Then list the three biggest things I would need to learn if I choose Next.js.

What to expect: A good answer ties the choice to SEO, login, hosting and your experience, rather than saying one is always better.

Key takeaways

  1. A Vite React app renders in the browser; Next.js can send ready-made HTML from the server, which helps first load and SEO.
  2. In the App Router, folders under app/ are routes, page.js is the page and layout.js wraps pages.
  3. Components are server components by default: they can be async and fetch data directly.
  4. Add 'use client' only to small interactive components that need state, effects or event handlers.
  5. loading.js, error.js and not-found.js handle waiting and failure states per route.
  6. Check the docs for your installed version's caching rules; pick Vite for logged-in apps and Next.js for public, search-driven sites.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why can a server-rendered page be better for SEO than a client-rendered SPA?

2. In the App Router, which file creates the page at the URL /about?

3. Your component needs useState for a like button. What must you do?

4. Where is the best place to put a secret API key your page needs?

5. You are building an internal admin dashboard that is always behind a login. Which is the most reasonable choice?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free