Back to Blog
Modern Next.js: choosing server and client boundaries
Web DevelopmentNext.jsReact

Modern Next.js: choosing server and client boundaries

Build responsive React applications with deliberate component boundaries, explicit data freshness, and accessible interactions.

A modern web application has several jobs: deliver useful content quickly, respond to interaction, protect private data, and stay understandable to the next developer. Choosing where code runs influences all four. In a Next.js App Router application, the server/client boundary is an architectural decision that deserves more attention than adding a directive to silence an error.

The examples in this article target Next.js 15, the major version used by this portfolio. Check the documentation for your installed version before adopting caching examples from a newer release.

Start with the content, then identify interactions

Imagine a project directory with a heading, project descriptions, a search box, and a contact button. Most of that page is content. Only the search box and its filtering behavior require browser state.

Keep the page responsible for loading public project summaries, then pass those summaries to a small interactive component. Do not send the full database record merely because the UI only displays a few properties. Shape the payload around what the browser needs.

// app/projects/page.tsx — illustrative data-access function
import ProjectSearch from './ProjectSearch';
import { listPublicProjects } from '@/lib/projects';

export default async function ProjectsPage() {
  const projects = await listPublicProjects();
  const summaries = projects.map(({ id, title, summary }) => ({
    id, title, summary,
  }));

  return <ProjectSearch projects={summaries} />;
}

In the App Router, pages and layouts are Server Components by default. Client Components support state, event handlers, and browser APIs. The use client directive establishes a client module boundary, and imported dependencies can become part of that client graph. Client Components can still participate in server prerendering; “client” does not mean the first HTML must be empty. These mechanics are explained in the Next.js 15 component documentation.

Keep derived state derived

A filter result is usually a function of the original list and current query. Storing that result in another state variable creates two values that must remain synchronized.

'use client';

import { useState } from 'react';

type Project = { id: string; title: string; summary: string };

export default function ProjectSearch({ projects }: { projects: Project[] }) {
  const [query, setQuery] = useState('');
  const normalized = query.trim().toLowerCase();
  const visible = projects.filter(project =>
    `${project.title} ${project.summary}`.toLowerCase().includes(normalized)
  );

  return (
    <section>
      <label htmlFor="project-search">Search projects</label>
      <input id="project-search" value={query}
        onChange={event => setQuery(event.target.value)} />
      <p role="status">{visible.length} projects found</p>
      <ul>{visible.map(project =>
        <li key={project.id}>{project.title}</li>
      )}</ul>
    </section>
  );
}

There is no effect here because no external system needs synchronization. React’s guide to avoiding unnecessary Effects explains why calculations during rendering often make state easier to reason about. If filtering becomes expensive, measure it before introducing memoization or a server search service.

Define freshness as a product requirement

A biography can tolerate a rebuild before changes appear. Inventory or account permissions may require a fresh lookup. A public article list may allow a short revalidation interval. Write down that requirement before choosing a cache policy.

For each data source, identify who may see it, how old it may be, and what invalidates it. A shared cache key that ignores tenant identity can return the wrong customer’s data. Adding a tenant to the key is still insufficient without authorization at the data-access boundary.

Do not infer application freshness from one fetch option alone. Generated route output, data fetching, browser behavior, and client navigation can introduce different reuse layers. Verify behavior in a production build with an actual update, a reload, and a client navigation. Development mode can hide the conditions users encounter after deployment.

Treat mutations as public entry points

A form can validate input in the browser for immediate feedback. The server must repeat validation and check authorization before writing. Hidden inputs, disabled buttons, and TypeScript interfaces are not trust boundaries.

For a contact form, establish maximum message length, handle malformed payloads, and return a useful success or failure state. Keep provider tokens on the server. A failed downstream request should not display “sent” just because the client completed its own validation.

Make loading and failure part of the design

A search with no results needs a clear empty state. A failed data request needs a retry path or a useful explanation. A button performing work needs a visible pending state and protection against accidental duplicate submissions where that matters.

Preserve keyboard focus during updates. Use a real button for actions and a link for navigation. Respect reduced-motion preferences for decorative transitions. These choices are easy to overlook when a page is judged only by a screenshot, but they define how usable the application feels.

Measure the whole user journey

Check the initial page, then test the first interaction on a slower device. Inspect the client bundle to see which imports crossed the boundary. Test long titles, empty data, denied access, and a failed network request. Compare the result with the simple version before adding another abstraction.

The best boundary is one you can explain: this data stays on the server, these fields reach the browser, and this component needs client execution for a specific interaction. That reasoning scales from a portfolio to a much larger application.

Related Posts

JavaScript concurrency: the event loop, races, and backpressure

JavaScript concurrency: the event loop, races, and backpressure

Understand async execution, avoid stale responses, and limit parallel work without blocking the interface.

Computer ScienceWeb DevelopmentJavaScript
Read More