Skip to content
elephantoo

Next.js & Server Components

Lesson 30 of 30 20 min read

Why frameworks exist, the App Router, Server vs Client Components, Server Functions and rendering strategies.


Everything so far has been a client-side React app: the server sends an almost empty HTML page and a JavaScript bundle, and the browser builds the UI. That's great for dashboards and tools, but it has trade-offs — slower first loads, weaker SEO, and API layers between your UI and your data. Next.js is the most popular React framework, and it solves these with server rendering, file-based routing and React Server Components. This final lesson gives you a working mental model and the essential APIs (Next.js 16, App Router).

Creating a project#

Terminal
npx create-next-app@latest elephantoo-blog
cd elephantoo-blog
npm run dev

Accept the recommended defaults (TypeScript, Tailwind CSS and the App Router; Turbopack is the default bundler). Open http://localhost:3000.

File-based routing#

In the App Router, folders inside app/ define URLs, and a page.tsx makes a route public:

Output
app/
├── layout.tsx          → wraps every page (required)
├── page.tsx            → /
├── about/
│   └── page.tsx        → /about
└── blog/
    ├── page.tsx        → /blog
    ├── loading.tsx     → shown while /blog pages load
    ├── error.tsx       → error boundary for /blog
    └── [slug]/
        ├── page.tsx    → /blog/hello-world, /blog/anything
        └── not-found.tsx

The root layout renders the <html> and <body>:

app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import "./globals.css";

export const metadata: Metadata = {
  title: { default: "Elephantoo Blog", template: "%s · Elephantoo" },
  description: "Friendly programming articles",
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <Link href="/">Home</Link> <Link href="/blog">Blog</Link>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}

Layouts persist across navigations (their state isn't lost), and next/link gives client-side navigation with automatic prefetching.

Server Components: fetch data right in the component#

In the App Router, components are Server Components by default. They run only on the server (at build time or per request), so they can be async, read files, query databases and use secret keys — and none of their code is sent to the browser:

app/blog/page.tsx
import Link from "next/link";

type Post = { id: number; title: string };

export const metadata = { title: "Blog" };

export default async function BlogPage() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
  if (!res.ok) throw new Error("Failed to load posts"); // caught by error.tsx
  const posts: Post[] = await res.json();

  return (
    <>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/blog/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

No useEffect, no loading state, no API route. The browser receives ready-made HTML, which is fast to display and easy for search engines to index. You could replace fetch with a direct database query (await db.post.findMany()), because this code never reaches the client.

Dynamic routes and params#

Folder names in brackets become params. In Next.js 16, params is a Promise you await:

app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";

type Props = { params: Promise<{ slug: string }> };

async function getPost(slug: string) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new Error("Failed to load post");
  return (await res.json()) as { id: number; title: string; body: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return { title: post?.title ?? "Post not found" };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound(); // renders not-found.tsx

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

generateMetadata sets the page <title> per post. Search params work the same way, via a searchParams promise prop.

Client Components: interactivity#

Server Components can't use state, effects, event handlers or browser APIs — they never run in the browser. For interactive parts, add "use client" at the top of the file:

app/blog/[slug]/LikeButton.tsx
"use client";

import { useState } from "react";

export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(false);

  return (
    <button
      aria-pressed={liked}
      onClick={() => {
        setLiked(!liked);
        setLikes(likes + (liked ? -1 : 1));
      }}
    >
      {liked ? "❤️" : "🤍"} {likes}
    </button>
  );
}

Then use it from a Server Component like any component:

TSX
import { LikeButton } from "./LikeButton";

export default async function PostFooter() {
  const likes = 42; // e.g. read from the database on the server
  return (
    <footer>
      <LikeButton initialLikes={likes} />
    </footer>
  );
}

Client Components are still pre-rendered to HTML on the server, then hydrated in the browser so they become interactive. "use client" marks a boundary: that file and everything it imports become part of the client bundle.

Choosing Server vs Client#

Use a Server Component to…Use a Client Component for…
Fetch data, query a databaseuseState, useReducer, useEffect
Keep secrets (API keys, tokens) on the serverEvent handlers (onClick, onChange)
Use large libraries without shipping them (Markdown, syntax highlighting)Browser APIs (localStorage, window, geolocation)
Render mostly static contentContext providers, most third-party UI widgets

The best practice is to keep pages and layouts as Server Components and push "use client" down to small, interactive leaves (buttons, forms, menus).

Composition rules

  • Server Components can import and render Client Components.
  • Client Components can't import Server Components — but they can receive them as children or other props:
TSX
// ✅ app/page.tsx (Server Component)
import { Tabs } from "./Tabs"; // a Client Component
import { CourseList } from "./CourseList"; // a Server Component

export default function Page() {
  return (
    <Tabs>
      <CourseList /> {/* rendered on the server, passed into the client component */}
    </Tabs>
  );
}
  • Props passed from Server to Client Components must be serialisable (strings, numbers, plain objects, arrays, Dates, Promises) — not functions or class instances, except Server Functions.

Server Functions: mutations with "use server"#

A Server Function is an async function that runs on the server but can be called from the client — most often as a form action. Mark it with "use server":

app/guestbook/actions.ts
"use server";

import { revalidatePath } from "next/cache";

const entries: { name: string; message: string }[] = []; // use a real database in practice

export async function getEntries() {
  return entries;
}

export async function addEntry(formData: FormData) {
  const name = String(formData.get("name") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim();
  if (!name || !message) return;

  entries.push({ name, message }); // runs on the server: safe to use secrets and databases
  revalidatePath("/guestbook"); // re-render the page with fresh data
}
app/guestbook/page.tsx
import { addEntry, getEntries } from "./actions";

export default async function GuestbookPage() {
  const entries = await getEntries();
  return (
    <>
      <h1>Guestbook</h1>
      <form action={addEntry}>
        <input name="name" placeholder="Your name" required />
        <input name="message" placeholder="Say hi!" required />
        <button>Sign</button>
      </form>
      <ul>
        {entries.map((e, i) => (
          <li key={i}>
            <strong>{e.name}</strong>: {e.message}
          </li>
        ))}
      </ul>
    </>
  );
}

This form even works before JavaScript loads (progressive enhancement). Everything from the React 19 features lesson applies: use useActionState for validation errors, useFormStatus for pending buttons and useOptimistic for instant feedback.

Tip: treat every Server Function like a public API endpoint — anyone can call it. Always validate input (e.g. with Zod) and check that the user is authenticated and authorised.

Loading and error UI#

app/blog/loading.tsx
export default function Loading() {
  return <p>Loading posts…</p>; // automatically wraps the page in <Suspense>
}
app/blog/error.tsx
"use client"; // error boundaries must be Client Components

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <div role="alert">
      <p>Something went wrong: {error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

You can also wrap slow parts of a page in your own <Suspense> boundaries so the rest streams to the browser immediately.

Rendering and caching in brief#

  • Static pages are rendered at build time and served from a CDN — ideal for blogs and docs. Use generateStaticParams to pre-render dynamic routes.
  • Dynamic pages render per request, e.g. when they read cookies or headers.
  • In Next.js 16, fetch isn't cached by default. With Cache Components enabled (cacheComponents: true in next.config.ts), you opt in to caching explicitly with the "use cache" directive and cacheLife, and mix static and dynamic parts on the same page.
  • next/image optimises images and next/font self-hosts fonts.

Deploying Next.js#

Unlike a Vite SPA, a Next.js app with server features needs a server runtime. Vercel (made by the Next.js team) supports everything out of the box; Netlify, Firebase App Hosting, Cloudflare and any Node.js host (npm run build && npm start, or Docker) work too. If you don't need server features, output: "export" produces a static site.

Next.js or Vite?#

  • Vite + React Router: dashboards, internal tools, apps behind a login where SEO doesn't matter, or when you already have a separate backend.
  • Next.js (or React Router's framework mode): public, content-heavy or SEO-sensitive sites, and full-stack apps that want UI and server code in one project.

Common mistakes#

  • Adding "use client" to every file "to make errors go away" — you lose the benefits of Server Components.
  • Using useState or onClick in a Server Component (Next.js shows an error that tells you to add "use client").
  • Importing server-only code (database clients, secrets) into a Client Component. The server-only package makes this a build error.
  • Forgetting to await params in Next.js 16.
  • Passing functions as props from Server to Client Components.
  • Not validating input or checking auth in Server Functions.

What's next#

🎉 Congratulations — you've completed the React course! You've gone from your first component to Server Components. Keep building: pick a project (a habit tracker, a portfolio, a course-progress dashboard), ship it, and revisit lessons as you need them. The official docs at react.dev and nextjs.org are excellent next stops.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In the Next.js App Router, components are ____ by default.

  2. 2.Which component must be a Client Component?

  3. 3.What does the "use server" directive mark?

Finished reading?

Mark this lesson complete to track your progress.