On this page
A Next.js codebase rots in predictable ways. A "use client" directive creeps to the top of a tree and drags half the app into the browser bundle. Data fetching and business logic pile up inside page.tsx until the route file is 300 lines of mixed concerns. Untyped JSON flows in from an API and spreads any through every component it touches. None of these are framework bugs. They are convention failures, and conventions are cheap to fix on day one and expensive to fix on day ninety.
This page sets up the conventions that hold the line: server-first by default, routes that stay thin, logic that lives in lib/, data typed where it enters the system, and client interactivity isolated to the smallest island that needs it. Follow it start to finish and you will end with a working feature that demonstrates every rule.
Why server-first is the default that matters
In the App Router, every layout and page is a Server Component unless you opt out. That is not a performance footnote, it is the security and architecture baseline. Server Components run only on the server, so they can read secrets, hit a database directly, and never ship their source or their dependencies to the browser. You reach for a Client Component only when you need state, event handlers, lifecycle effects, or a browser-only API like localStorage or window.
Treat the boundary as a one-way door. Once a file carries "use client", every module it imports and every child it renders becomes part of the client bundle. Keep that door as far down the tree as possible.
Understand the server default with awaited params
Start with a route that proves the server default. In Next.js 16, params is a Promise, so you must await it. This is the single most common upgrade trip-up, so internalize it now.
import { getPost } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}This component is async, runs only on the server, and ships zero JavaScript for itself. The await params line is mandatory: params is no longer a plain object, and reading params.id directly will fail typechecking and at runtime. The same rule applies to searchParams, which is also a Promise.
Keep the route thin and put logic in lib
The route file you just wrote calls getPost, but it does not define it. That is deliberate. A route's job is to wire data to UI, not to own fetching, parsing, or validation. Push that work into a module under lib/.
import "server-only";
export type Post = {
id: string;
title: string;
body: string;
};
export async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${id}`, {
headers: { authorization: `Bearer ${process.env.POSTS_API_KEY}` },
});
if (!res.ok) {
throw new Error(`Failed to load post ${id}: ${res.status}`);
}
return parsePost(await res.json());
}The route stays readable: it reads the param, calls one function, renders the result. The data access, the API key, and the error handling live in one place you can test and reuse. Because getPost is a plain async function, any Server Component can call it directly without an internal API round trip.
Keep page.tsx, layout.tsx, and route.ts thin. They orchestrate. Data access, validation, and integration logic belong in lib/, imported by name.
Type the data where it enters the system
The getPost function above returns parsePost(await res.json()), not the raw JSON. res.json() is typed any, and any spreads. The fix is to validate and type the data once, at the edge where it enters your app, so everything downstream is honestly typed.
function parsePost(data: unknown): Post {
if (typeof data !== "object" || data === null) {
throw new Error("Unexpected post shape from API");
}
const record = data as Record<string, unknown>;
if (
typeof record.id !== "string" ||
typeof record.title !== "string" ||
typeof record.body !== "string"
) {
throw new Error("Unexpected post shape from API");
}
return { id: record.id, title: record.title, body: record.body };
}Now getPost truly returns a Post. The route, the page, and any client island that receives this data all get real types, and a malformed API response throws at the boundary instead of rendering undefined three components deep. If you prefer a schema library like Zod, the principle is identical: parse untrusted input into a known type once, at the seam.
Do not pass await res.json() or any any-typed value into your component tree. Validate it into a named type at the edge. Untyped data at the boundary becomes untyped data everywhere.
Add a client island, correctly
Now add interactivity: a button that lets the reader save a post locally. This needs useState and localStorage, so it must be a Client Component. The discipline is to make only the button a client island, not the page that holds it.
"use client";
import { useState } from "react";
export function SaveButton({ postId }: { postId: string }) {
const [saved, setSaved] = useState(false);
function save() {
localStorage.setItem(`saved:${postId}`, "1");
setSaved(true);
}
return (
<button onClick={save} disabled={saved}>
{saved ? "Saved" : "Save for later"}
</button>
);
}Drop it into the server page. The page stays a Server Component; only the button hydrates.
import { getPost } from "@/lib/posts";
import { SaveButton } from "./save-button";
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<SaveButton postId={post.id} />
</article>
);
}The server fetches and renders the post; the client bundle contains only the button and React's state machinery. The props you pass across the boundary (here, postId) must be serializable, which a plain string is.
The mistake to avoid: a client boundary set too high
The most common altitude failure is marking the page itself as a client component because one element inside it is interactive. That pulls your data fetching, your lib/ imports, and any secrets they touch into the browser graph, and it ships the entire page as JavaScript.
Avoid
"use client";
import { useState } from "react";
import { getPost } from "@/lib/posts"; // now in the client graph
// Cannot await params cleanly, cannot fetch on the server,
// and getPost's secret-bearing module is dragged client-side.
export default function PostPage() {
const [saved, setSaved] = useState(false);
// ...the whole page is now a client bundle
}Prefer
import { getPost } from "@/lib/posts";
import { SaveButton } from "./save-button"; // the only client island
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getPost(id); // stays on the server
return <SaveButton postId={post.id} />;
}The right shape is a server page that fetches data and renders a small client component for the interactive part. When you genuinely need client state to wrap server-rendered UI, pass the server content as children into the client component rather than importing server modules into it, and render context providers as deep in the tree as they will go.
Push "use client" to the leaf that needs it. The page stays a Server Component; the interactive widget is the island. Interactivity should never decide how the page fetches data.
Cleanliness rules that keep the codebase legible
A few standing rules keep the conventions above from eroding:
- No dead code. Delete unused imports, commented-out blocks, and
console.logcalls before you commit. A linter withno-unused-varsand a rule against strayconsolestatements enforces this for you. - Colocate non-routable files safely. Private folders prefixed with an underscore, like
app/posts/_components/, are opted out of routing, so you can keep feature-local UI next to the route without exposing a URL. Asave-button.tsxsitting besidepage.tsxis fine too, because onlypage.tsxandroute.tsare routable. - Keep providers shallow in scope, deep in the tree. A theme or auth provider is a Client Component, so wrap only
{children}with it inside a layout, never the whole<html>document. That keeps the static parts of your Server Components optimizable. - One source of truth per concern. Data access in
lib/, UI in components, validation at the edge. Do not let a route grow a second copy of fetch logic that already exists inlib/.