Unity WebLabs← unityweb.studio

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.

8 min read

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.