Writing
Four App Router patterns we reach for first
Server Components, Server Actions, streaming, and the trimmed-down data fetching patterns we use on every Next.js project.
Next.js is a big surface. Every App Router project uses maybe 15% of what the framework ships. These four patterns are the ones we reach for before anything else, on every project that crosses our desk.
1. Server Components by default
Start as a Server Component. Opt into 'use client' only when the browser genuinely needs the code — stateful forms, animations, third-party hooks. This is the default that ships zero JS to the client.
// app/dashboard/page.tsx — Server Component by default import { db } from '@/lib/db'; export default async function Dashboard() { const projects = await db.project.findMany(); return ( <ul> {projects.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> ); }
The mental model: if the user doesn't need to interact with it, keep it on the server.
2. Server Actions for mutations
Server Actions replace the fetch-wrapper-layer you used to write. Declare a function with 'use server', call it from the form, get progressive-enhancement and revalidation for free.
// app/projects/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { db } from '@/lib/db'; export async function createProject(formData: FormData) { const name = formData.get('name') as string; await db.project.create({ data: { name } }); revalidatePath('/dashboard'); }
For anything beyond trivial, pair the action with useActionState or useTransition for pending/error UI. See the /pagespeed demo on this site for a worked example.
3. Streaming with Suspense
The page shell renders instantly; slow data arrives when it's ready. One <Suspense> boundary per async block; the fallback is the skeleton the user sees.
// app/dashboard/page.tsx import { Suspense } from 'react'; import { ProjectList } from './project-list'; import { ProjectListSkeleton } from './project-list-skeleton'; export default function Dashboard() { return ( <> <h1>Projects</h1> <Suspense fallback={<ProjectListSkeleton />}> <ProjectList /> </Suspense> </> ); }
4. Compose client + server
The nuance most teams miss: a client component can render server-component children passed as props. That lets you gate interactivity without converting whole subtrees.
// A client wrapper that accepts a server-rendered child 'use client'; import { useState, type ReactNode } from 'react'; export function Expandable({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); return ( <div> <button onClick={() => setOpen(!open)}> {open ? 'Hide' : 'Show'} </button> {open && children} </div> ); }
This is how the /case-studies demo on this site works: the outer Server Component ships static HTML, the TanStack Query wrapper lights up the filter UI only.
What we don't reach for
Route groups for pure organization: usually unnecessary. loading.tsx files: we prefer per-component Suspense. error.tsx files: yes, at the segment level. Middleware: only for auth / redirects / rewrites. Anything else can live in a Server Action or an RSC.
Start with these four. Add more when a real problem makes you.