Le Do Nghiem

Le Do Nghiem

AI Engineer

About meBooksSnippetsBlog

© 2026 Le Do Nghiem. All rights reserved.

Contact |

Back to Blog

Understanding 'use client' and 'use server' in Next.js

Le Do Nghiem
Le Do NghiemAI Engineer
2025-12-18 4 min read
Share

A mistake I made early

My first App Router mistake was putting 'use client' at the top of a layout because one button needed onClick. The whole subtree — including data fetching I thought was "server" — shipped JavaScript I did not need.

Next.js defaults to Server Components. 'use client' is the escape hatch. 'use server' marks functions that run on the server but can be invoked from the client.

This post is how I draw those lines now. Pair with What's New in Next.js 15 for caching context and JWT vs sessions when auth cookies meet Server Components.


Server Components — the default

import { db } from "@/lib/db";

export default async function BlogPage() {
  const posts = await db.post.findMany();
  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

What you get:

  • No client JS for this component's logic
  • Direct DB/API access
  • Secrets stay on server
  • HTML arrives ready for SEO

What you cannot do: useState, useEffect, onClick, window.


When I use 'use client'

Only when the component needs:

  1. Browser APIs — window, localStorage, observers
  2. Event handlers — onClick, onChange
  3. React state/effect hooks
  4. Context providers that hold client state
"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}

Rule I follow: Push 'use client' as far down the tree as possible — wrap the interactive leaf, not the page.


When I use 'use server'

Server Actions — async functions that run on the server, called from forms or client code.

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

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  await db.post.create({ data: { title, content } });
  revalidatePath("/posts");
}
"use client";

import { createPost } from "@/app/actions";

export default function PostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Create</button>
    </form>
  );
}

Why: No hand-rolled API route for every mutation if the action fits.


The boundary mistake (my story)

// Bad — whole page client because of one button
"use client";
import { db } from "@/lib/db"; // does not work — server-only

export default function Page() {
  return <InteractiveButton />;
}
// Good — server page, client leaf
import InteractiveButton from "./InteractiveButton";

export default async function Page() {
  const data = await db.query();
  return (
    <div>
      <h1>{data.title}</h1>
      <InteractiveButton />
    </div>
  );
}

Lesson: 'use client' is contagious to imports. Minimize the infected subtree.


Decision cheat sheet

  • Static text, data fetch, SEO → Server Component (default)
  • Button, form state, animation → Client Component (small)
  • Mutation from form → Server Action ('use server')
  • Read on server, write from client → Server page + Server Action + tiny client form
  • Auth session in httpOnly cookie → read on server; see JWT vs sessions

Common mistakes I still see

Unnecessary 'use client' on static markup — costs bundle for zero benefit.

Browser APIs in Server Components — window is not defined. Split the component.

Giant client providers at root — theme OK; entire app state tree not OK if avoidable.


Performance snapshot

Server ComponentClient Component
JS to browserNone (this component)Bundled
Data accessDirectVia fetch/actions
InteractivityNoYes

Server Actions add server round-trips — still often simpler than REST boilerplate for forms.


Boundary mistakes I made

  • Client layout wrapping everything — fixed by moving provider only around branches that need it.
  • Fetching in useEffect what the server could fetch once — slower, worse UX.
  • Forgetting revalidatePath after Server Actions — stale lists until hard refresh.

The habit that matters

Open your largest 'use client' file. Ask: does this file need the browser, or only a child? Split.

Default server. Add client at the leaves. Use Server Actions for mutations. Re-read React 19 Actions when you want pending states without manual wiring.

That is most of App Router performance and security in one habit: small client islands.

On this page

  • A mistake I made early
  • Server Components — the default
  • When I use 'use client'
  • When I use 'use server'
  • The boundary mistake (my story)
  • Decision cheat sheet
  • Common mistakes I still see
  • Performance snapshot
  • Boundary mistakes I made
  • The habit that matters
Share
Previous Post

JWT vs Session Authentication: Choosing the Right Approach

Next Post

Mastering RxJS in Angular: Reactive Programming Patterns