---
title: 'Next.js 16 Streaming Tutorial: Suspense + use cache 2026'
description: 'Hands-on Next.js 16.2 tutorial: stream Server Components with Suspense and loading.tsx, then cache them with use cache, cacheLife, and updateTag in 2026.'
url: https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial
language: en
date: '2026-05-12'
category: frontend
tags:
  - nextjs
  - react
  - server-components
  - suspense
  - streaming
  - use-cache
  - cachelife
  - typescript
  - frontend
markdown_url: https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Next.js 16 Streaming Tutorial: Suspense + use cache 2026

Streaming in Next.js 16 finally feels like one coherent story instead of three half-finished APIs. You wrap an async Server Component in `<Suspense>`, the server sends the shell over chunked transfer encoding, and a fast-streamed fallback hands off to real content as each query resolves[^1]. What changed in 16.2 is the *caching* half of the picture: `use cache`, `cacheLife`, `cacheTag`, and `updateTag` are now stable, the `unstable_` prefix is gone, and Turbopack is the default bundler for both `dev` and `build`[^2]. This 2026 tutorial wires those primitives into a single runnable project so you can stream a slow page, cache only the parts that benefit from it, invalidate the cache on a Server Action, and watch read-your-writes work end-to-end.

## TL;DR

You'll build a small "live posts" page in Next.js 16.2.6 that streams two independent server-rendered sections, falls back through `loading.tsx` for the route shell and `<Suspense>` for sibling cards, opts into the new caching model with `cacheComponents: true`, marks the slow data fetcher with `'use cache'` plus a `cacheLife('minutes')` profile, and ends with a Server Action that calls `updateTag` so the next render shows the user's mutation immediately. Total build time: 25–35 minutes. No external services required; everything runs on localhost.

## What you'll learn

- How streaming actually works in Next.js 16 — what the server sends, what the browser does, and why Suspense is the boundary that matters
- How to choose between `loading.tsx` and `<Suspense>` for route-level vs. component-level fallbacks
- How to mark a slow Server Component as cacheable with the `'use cache'` directive
- How to control cache lifetime with `cacheLife` profiles and tag for invalidation with `cacheTag`
- How to invalidate the right cache from a Server Action using `updateTag` vs. `revalidateTag`
- How to migrate to async `params` and `searchParams` (a breaking change in Next.js 16)
- How to verify the streaming works at the network layer with `curl --no-buffer`

## Prerequisites

- Node.js 22 LTS (Next.js 16 dropped Node 18; minimum is 20.9, but 22 LTS is the recommended baseline)[^3]
- pnpm 9+ or npm 10+
- A terminal with `curl` available for the streaming verification step
- Editor with TypeScript 5.7+ language server (TypeScript 6.0.3 is current; either will work[^4])
- Basic familiarity with React Server Components — if any of this is new, read [Mastering Next.js App Router patterns for scalable web apps](https://nerdleveltech.com/mastering-nextjs-app-router-patterns-for-scalable-web-apps) first

Pinned dependencies for this tutorial (no `latest` tags — version drift is documented in this site's [Next.js 16 migration tutorial](https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial)):

| Package          | Version  | Why                                       |
|------------------|----------|-------------------------------------------|
| `next`           | 16.2.6   | Latest stable as of 2026-05-12[^5]        |
| `react`          | 19.2.6   | Required by Next.js 16[^6]                |
| `react-dom`      | 19.2.6   | Required by Next.js 16                    |
| `@types/react`   | 19.2.14  | TS types matching React 19.2              |
| `typescript`     | 5.7.3    | Stable LTS line; 6.0.x also works         |

## Step 1 — Scaffold the project

```bash
pnpm dlx create-next-app@16.2.6 stream-cache-demo \
  --typescript --app --no-tailwind --no-eslint --no-src-dir \
  --import-alias '@/*' --use-pnpm
cd stream-cache-demo
```

The flags pin the scaffold to Next.js 16.2.6, opt into the App Router, skip Tailwind/ESLint to keep the tutorial tight, and disable the `src/` layout so the file paths in this post match what you see on disk.

Verify the install:

```bash
pnpm next --version
# Expected: 16.2.6
node --version
# Expected: v22.x.y (or v20.9.0+ if you're on the old LTS)
```

## Step 2 — Opt into the new caching model

Next.js 16's caching is **opt-in by default**. Without the `cacheComponents` flag, all your dynamic Server Components run at request time and nothing is cached unless you explicitly mark it. Flip the switch in `next.config.ts`:

```ts
// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
  // Custom profile for our streaming feed; sits alongside built-ins like 'seconds', 'minutes', and 'max'.
  cacheLife: {
    feed: { stale: 30, revalidate: 60, expire: 600 },
  },
};

export default nextConfig;
```

Two things to notice:

1. `cacheComponents: true` is the renamed, stabilized successor to `experimental.dynamicIO` from Next.js 15. It enables Partial Prerendering-style behavior: a static shell streams immediately, dynamic sections stream as their data resolves[^7].
2. The `cacheLife` config object lets you define custom named profiles. `'feed'` here means: clients may serve the cached value for 30 seconds without checking the server, a background refresh starts after 60 seconds, and an unrequested entry is hard-evicted after 10 minutes. Built-in named profiles (`seconds`, `minutes`, `hours`, `days`, `weeks`, `max`) are always available alongside your custom ones[^8].

## Step 3 — Fake a slow database

For a runnable tutorial you don't want to drag in Postgres, so simulate latency with a typed in-memory store. Create `lib/db.ts`:

```ts
// lib/db.ts
import 'server-only';

export type Post = {
  id: string;
  title: string;
  author: string;
  body: string;
  createdAt: string;
};

const store: Post[] = [
  {
    id: '1',
    title: 'Streaming the App Router',
    author: 'Ada',
    body: 'Server Components stream HTML over chunked transfer encoding...',
    createdAt: '2026-05-10T10:00:00.000Z',
  },
  {
    id: '2',
    title: 'Why use cache is opt-in',
    author: 'Grace',
    body: 'Next.js 16 flipped the default: dynamic at request time unless you opt in.',
    createdAt: '2026-05-11T09:30:00.000Z',
  },
];

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function getPosts(): Promise<Post[]> {
  // Simulate a slow analytical query
  await sleep(1200);
  return store.slice().sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}

export async function getStats(): Promise<{ totalPosts: number; uniqueAuthors: number }> {
  // Simulate a fast aggregate
  await sleep(200);
  const uniqueAuthors = new Set(store.map((p) => p.author)).size;
  return { totalPosts: store.length, uniqueAuthors };
}

export async function createPost(input: Omit<Post, 'id' | 'createdAt'>): Promise<Post> {
  await sleep(150);
  const post: Post = {
    id: String(store.length + 1),
    ...input,
    createdAt: new Date().toISOString(),
  };
  store.unshift(post);
  return post;
}
```

The `import 'server-only'` line is a Next.js convention that throws at build time if a Client Component ever pulls this module — a cheap safety net for tutorials and a great habit in production code.

## Step 4 — Render the page without caching, just streaming

This is the simplest streaming pattern: a single async Server Component with `loading.tsx` as the route-level fallback.

Create `app/page.tsx`:

```tsx
// app/page.tsx
import { Suspense } from 'react';
import { getPosts, getStats } from '@/lib/db';

export const dynamic = 'force-dynamic'; // make sure we don't accidentally prerender

type PageProps = {
  searchParams: Promise<{ author?: string }>;
};

export default async function HomePage({ searchParams }: PageProps) {
  // Next.js 16: searchParams is now a Promise
  const { author } = await searchParams;

  return (
    <main style={{ maxWidth: 720, margin: '40px auto', fontFamily: 'system-ui' }}>
      <h1>Live posts {author ? `by ${author}` : ''}</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <Posts authorFilter={author} />
      </Suspense>
    </main>
  );
}

async function Stats() {
  const { totalPosts, uniqueAuthors } = await getStats();
  return (
    <p style={{ color: '#666' }}>
      <strong>{totalPosts}</strong> posts from <strong>{uniqueAuthors}</strong> authors.
    </p>
  );
}

async function Posts({ authorFilter }: { authorFilter?: string }) {
  const posts = await getPosts();
  const filtered = authorFilter
    ? posts.filter((p) => p.author.toLowerCase() === authorFilter.toLowerCase())
    : posts;
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {filtered.map((p) => (
        <li key={p.id} style={{ borderTop: '1px solid #eee', padding: '12px 0' }}>
          <h3 style={{ margin: 0 }}>{p.title}</h3>
          <small>
            by {p.author} on {new Date(p.createdAt).toLocaleString()}
          </small>
          <p>{p.body}</p>
        </li>
      ))}
    </ul>
  );
}

function StatsSkeleton() {
  return <p style={{ color: '#aaa' }}>Loading stats…</p>;
}

function PostsSkeleton() {
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {[1, 2, 3].map((i) => (
        <li key={i} style={{ borderTop: '1px solid #eee', padding: '12px 0' }}>
          <div style={{ height: 16, width: '60%', background: '#eee' }} />
          <div style={{ height: 12, width: '30%', background: '#f3f3f3', margin: '6px 0' }} />
          <div style={{ height: 12, width: '90%', background: '#f3f3f3' }} />
        </li>
      ))}
    </ul>
  );
}
```

A few things worth pinning down:

- **Async params/searchParams.** Next.js 16 made `params` and `searchParams` Promise-typed. Awaiting them is mandatory — using `searchParams.author` directly is a compile-time and runtime error[^9]. If you have a Next.js 15 codebase, run `npx @next/codemod@canary upgrade latest` to automate the rewrite[^10].
- **Two sibling Suspense boundaries.** `Stats` returns in ~200ms; `Posts` takes ~1200ms. With both wrapped, the page paints the heading immediately, swaps the stats fallback at ~200ms, and swaps the posts fallback at ~1200ms — no waterfall.
- **`dynamic = 'force-dynamic'`.** Belt-and-braces for this step: we'll remove it once we add `use cache` so you can see the difference.

Optional route-level fallback. Create `app/loading.tsx`:

```tsx
// app/loading.tsx
export default function Loading() {
  return (
    <main style={{ maxWidth: 720, margin: '40px auto', fontFamily: 'system-ui' }}>
      <h1 style={{ color: '#aaa' }}>Loading…</h1>
    </main>
  );
}
```

`loading.tsx` automatically wraps `page.tsx` (and any nested route segments below it) in a `<Suspense>` boundary that uses this file as its fallback[^11]. It's the right tool when you want a single page-wide shell to stream immediately; the layout (your `app/layout.tsx`) stays interactive so the user can keep clicking the nav while the page swaps in.

Start the dev server:

```bash
pnpm next dev
```

Open `http://localhost:3000`. You should see the heading first, the stats appear after ~200ms, and the post list appear after ~1200ms. That's streaming working.

## How do I stream server components in Next.js 16?

Wrap an async Server Component in `<Suspense fallback={...}>` (or place a `loading.tsx` next to the route's `page.tsx`), and Next.js will send the static shell plus your fallback over chunked transfer encoding immediately, then stream the async component's HTML as soon as its data resolves[^12]. There is no separate streaming API to enable — every async Server Component automatically participates as long as a Suspense boundary exists somewhere above it.

To prove the chunks really arrive in stages, run this against your dev server:

```bash
curl --no-buffer -N http://localhost:3000 | head -c 4000
```

You'll see HTML print to the terminal in two visible pauses — the shell first, then the `Stats` chunk, then the `Posts` chunk. Each chunk is wrapped in `<script>$RC(...)</script>` calls that React uses to swap the streamed content into the right Suspense slot[^13].

## When should I use loading.tsx vs Suspense in Next.js?

Use `loading.tsx` when the entire route is async and you want a single page-wide shell that streams immediately while keeping the layout interactive; use `<Suspense>` when one slow data dependency would otherwise block faster siblings and you want piece-by-piece streaming inside the page. They're not exclusive — `loading.tsx` provides the *outer* boundary, and `<Suspense>` provides the *inner* boundaries.

The practical heuristic:

| Pattern                                  | Reach for                                |
|------------------------------------------|------------------------------------------|
| Whole route is slow; one fallback is fine | `loading.tsx`                           |
| Page has a fast stat + a slow list        | Sibling `<Suspense>` inside page.tsx     |
| Layout reads runtime data                 | Wrap the layout's runtime read in `<Suspense>` (loading.tsx won't cover layouts that themselves await runtime data) |
| Optimistic UI on a form                   | `<Suspense>` + `useTransition` together  |

## Step 5 — Cache the slow query with `'use cache'`

Now the new part. Posts don't really need to be revalidated on every request — let's serve a 60-second-fresh cached copy that revalidates in the background. Modify `lib/db.ts` and add a cached variant of `getPosts`:

```ts
// lib/db.ts (additions)
import { cacheLife } from 'next/cache';
import { cacheTag } from 'next/cache';

export async function getCachedPosts(): Promise<Post[]> {
  'use cache';
  cacheLife('feed'); // matches the profile in next.config.ts
  cacheTag('posts'); // a label we can invalidate later
  return getPosts();
}
```

Three things happen here:

1. **`'use cache'`** marks the function's return value as cacheable. Next.js's compiler automatically generates a cache key from the function arguments — no `keyParts` argument required[^14].
2. **`cacheLife('feed')`** applies the profile we defined in `next.config.ts`: 30s stale window, 60s background revalidate, 10-minute hard expire. You can also pass a built-in profile name (`'minutes'`, `'hours'`, `'max'`) or an inline object like `{ stale: 60, revalidate: 300, expire: 3600 }`[^15].
3. **`cacheTag('posts')`** stamps a tag onto this cache entry. We'll use it from a Server Action in Step 7 to invalidate selectively.

Swap the call inside `app/page.tsx`:

```tsx
// app/page.tsx (replace the Posts component)
import { getCachedPosts } from '@/lib/db';

async function Posts({ authorFilter }: { authorFilter?: string }) {
  const posts = await getCachedPosts();
  const filtered = authorFilter
    ? posts.filter((p) => p.author.toLowerCase() === authorFilter.toLowerCase())
    : posts;
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {filtered.map((p) => (
        <li key={p.id} style={{ borderTop: '1px solid #eee', padding: '12px 0' }}>
          <h3 style={{ margin: 0 }}>{p.title}</h3>
          <small>
            by {p.author} on {new Date(p.createdAt).toLocaleString()}
          </small>
          <p>{p.body}</p>
        </li>
      ))}
    </ul>
  );
}
```

You can drop `export const dynamic = 'force-dynamic'` now — the `'use cache'` directive is what controls whether this branch is cached, not the page-level flag. This is the move that defines the modern Next.js 16 streaming model: the static shell streams instantly, the cached posts section streams from the cache when it's warm, and only the uncached pieces pay the data-fetch cost on every request.

Reload `http://localhost:3000` twice. The first load takes ~1200ms for the post list (the cache is empty). The second load returns the cached payload in single-digit milliseconds. Refresh again at the 30-second mark and you'll see the stale value served instantly while a background refresh happens.

## What does the use cache directive do in Next.js 16?

The `'use cache'` directive marks a function, component, or whole file as cacheable; Next.js generates a cache key from the function's arguments and any closure-captured values, stores the rendered output in the data and full-route caches, and serves the stored value (subject to the active `cacheLife` profile) on subsequent requests across the cluster[^16]. It replaces both the implicit `fetch` cache and the older `unstable_cache` helper from the Next.js 15 era — and unlike those, it's compiler-aware, so you don't manually pass a `keyParts` array.

A short rules of thumb table:

| Scope                              | Where to put `'use cache'`                  |
|------------------------------------|---------------------------------------------|
| Cache one function's return value  | First line inside the function body         |
| Cache an entire module's exports   | First line at the top of the file           |
| Cache a single Server Component    | First line inside the component body        |
| Don't cache anything in this branch| Omit the directive — request-time by default |

## Step 6 — Compose `cacheTag` so you can invalidate later

`cacheTag` is the verb; the tag is the noun. You can attach multiple tags to a single cache entry — useful when the same entry should be invalidated by either an author-level event ("Ada published a post") or a global event ("post catalog rebuilt"):

```ts
// lib/db.ts (variation)
export async function getCachedPostsByAuthor(author: string): Promise<Post[]> {
  'use cache';
  cacheLife('feed');
  cacheTag('posts');
  cacheTag(`posts:author:${author}`);
  const all = await getPosts();
  return all.filter((p) => p.author === author);
}
```

The same cache entry now responds to invalidation by `'posts'` (everything) or `'posts:author:Ada'` (just Ada's slice). The granularity is yours; the rule of thumb is one global tag plus one or two scoped tags per cached function.

## Step 7 — Mutate, then invalidate with `updateTag`

Time for the part most streaming tutorials skip. A user creates a post via a Server Action; immediately after the action returns, they're going to navigate back to the home page expecting to see their new post. With `revalidateTag`, the cache invalidates with stale-while-revalidate semantics — meaning the user might still see the stale list on the next render. With `updateTag`, the cache is expired *and* refreshed within the same request, so the navigation lands on a fresh render. That's the read-your-writes guarantee[^17].

Create `app/new/page.tsx`:

```tsx
// app/new/page.tsx
import { redirect } from 'next/navigation';
import { updateTag } from 'next/cache';
import { createPost } from '@/lib/db';

async function createPostAction(formData: FormData) {
  'use server';
  const title = String(formData.get('title') ?? '').trim();
  const author = String(formData.get('author') ?? '').trim();
  const body = String(formData.get('body') ?? '').trim();
  if (!title || !author || !body) {
    throw new Error('All fields are required');
  }
  await createPost({ title, author, body });
  // Read-your-writes: expire AND refresh within this request
  updateTag('posts');
  redirect('/');
}

export default function NewPostPage() {
  return (
    <main style={{ maxWidth: 720, margin: '40px auto', fontFamily: 'system-ui' }}>
      <h1>New post</h1>
      <form action={createPostAction} style={{ display: 'grid', gap: 12 }}>
        <label>
          Title <input name="title" required style={{ width: '100%' }} />
        </label>
        <label>
          Author <input name="author" required style={{ width: '100%' }} />
        </label>
        <label>
          Body <textarea name="body" required rows={5} style={{ width: '100%' }} />
        </label>
        <button type="submit">Publish</button>
      </form>
    </main>
  );
}
```

Submit the form. The action runs server-side, mutates the in-memory store, calls `updateTag('posts')` to expire the `'posts'`-tagged entries, and redirects back to `/`. The home page's `Posts` component re-renders with the updated list immediately — no stale flash.

## What is the difference between updateTag and revalidateTag in Next.js 16?

`updateTag` and `revalidateTag` both invalidate cache entries by tag, but `updateTag` does it with read-your-writes semantics (expire and refresh within the same request, served fresh on the next render) while `revalidateTag` uses stale-while-revalidate semantics (serve the stale value immediately, refresh in the background)[^18]. Use `updateTag` after a user-initiated mutation when the user will navigate to a page that must show the change; use `revalidateTag` from webhooks or background jobs when slight delay is acceptable.

Two key constraints worth memorizing:

- `updateTag` is **Server-Action-only**. It throws if called from a Route Handler, a Client Component, or middleware.
- `revalidateTag` now takes a `cacheLife` profile as its second argument: `revalidateTag('posts', 'max')`. The recommended default is `'max'` (longest-lived SWR), unless you need immediate hard expiration (`{ expire: 0 }` as the second arg)[^19].

| Use case                                  | Reach for                                  |
|-------------------------------------------|--------------------------------------------|
| User submits a form; will see the result  | `updateTag` inside the Server Action       |
| Webhook fires from a CMS                  | `revalidateTag('cms', 'max')` in a route handler |
| Need the next visitor to wait for fresh   | `revalidateTag('cms', { expire: 0 })`      |
| Cron job rebuilds a slow aggregate        | `revalidateTag('aggregate', 'max')`        |

## Step 8 — Verify streaming at the network layer

The browser DevTools network panel often *displays* the request as a single response — the chunks are real but the UI hides them. Use `curl --no-buffer` to confirm:

```bash
curl --no-buffer -N -s http://localhost:3000 \
  | tr -d '\n' \
  | tr '>' '>\n' \
  | grep -nE 'Loading stats|Loading posts|by Ada|by Grace' \
  | head -20
```

Run that command and watch the matches stream past the terminal. You should see "Loading stats" and "Loading posts" land first (the fallbacks), followed by lines referencing the streamed content. If the entire output prints at once with no visible delay, streaming is **not** working — most likely cause is a proxy or compression middleware buffering the response[^20].

You can also inspect the HTTP-level evidence:

```bash
curl -sI http://localhost:3000 | grep -i 'transfer-encoding\|content-encoding'
# Expected:
# Transfer-Encoding: chunked
```

`chunked` is the smoking gun — there's no `Content-Length` because the body length isn't known up front, and that's what allows progressive flushing.

## Step 9 — A common gotcha: don't read runtime data in a layout that wraps cached pages

If your `app/layout.tsx` reads anything dynamic (cookies, headers, the request URL) and is not itself wrapped in a `<Suspense>` boundary, the entire route below it has to wait for that read before the shell can flush. The fix is to push the runtime read into its own client component, or to wrap a small server fragment in `<Suspense>` so the rest of the layout can ship immediately[^21].

Pattern:

```tsx
// app/layout.tsx (corrected)
import { Suspense } from 'react';
import { CurrentUser } from './_components/current-user';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <nav>
          <a href="/">Home</a> · <a href="/new">New post</a> ·
          <Suspense fallback={<span style={{ color: '#999' }}>…</span>}>
            <CurrentUser />
          </Suspense>
        </nav>
        {children}
      </body>
    </html>
  );
}
```

`CurrentUser` can now `await cookies()` without blocking the rest of the page from streaming.

## Verification

Run through the full checklist:

```bash
# 1. Confirm dev server boots on Next.js 16.2.6
pnpm next --version
# Expected: 16.2.6

# 2. Confirm chunked transfer encoding is on
curl -sI http://localhost:3000 | grep -i transfer-encoding
# Expected: Transfer-Encoding: chunked

# 3. Confirm streaming actually streams (you'll see two visible pauses)
time curl --no-buffer -N -s http://localhost:3000 > /dev/null
# Expected: real time between 1.2s and 1.5s

# 4. Confirm caching kicks in on the second load (sub-200ms)
time curl -s http://localhost:3000 > /dev/null
# Expected: real time under 200ms

# 5. Submit a new post and verify it appears immediately
curl -s -X POST -F 'title=Test' -F 'author=Tutorial' -F 'body=Hello' \
  http://localhost:3000/new -i | head -3
# Expected: HTTP/1.1 303 See Other with Location: /
```

If all five pass, you've shipped a streaming + caching pipeline that's correct from the request boundary all the way through to read-your-writes invalidation.

## Troubleshooting

**`Type error: Property 'author' does not exist on type 'Promise<...>'`.** You forgot to `await` `params` or `searchParams`. In Next.js 16 they're Promises; the codemod (`npx @next/codemod@canary upgrade latest`) fixes most call sites automatically[^10].

**`use cache is not allowed inside a synchronous function`.** The function containing `'use cache'` must be `async`. The directive applies to the return value of an async function, not to a sync helper. Convert the function signature to `async` and the error goes away.

**`cacheTag was called outside of a "use cache" scope`.** Both `cacheTag` and `cacheLife` must be invoked inside a function (or file) whose first statement is the `'use cache'` directive. If you see this error, move the call below the directive, or hoist the entire logic into a `'use cache'` function.

**`updateTag is only available inside Server Actions`.** You called `updateTag` from a Route Handler, a Client Component, or somewhere outside a `'use server'` boundary. Move the call into a Server Action; if you genuinely need to invalidate from a webhook, use `revalidateTag(tag, 'max')` instead.

**Streaming "works" in dev but not in production.** Check your reverse proxy. A misconfigured Nginx (`proxy_buffering on`, the default) will buffer the entire response and break chunked streaming end-to-end. Add `proxy_buffering off` for the Next.js upstream, or test directly against `node`/`pnpm next start` to confirm the framework is shipping chunks before blaming Next.js[^22].

## Next steps

You now have streaming, opt-in caching, and read-your-writes invalidation in a single page. From here:

- Wire a real database — for Postgres, start with [Production Postgres pooling with PgBouncer & Supabase Supavisor](https://nerdleveltech.com/production-postgres-pooling-pgbouncer-supabase-supavisor-tutorial)
- Add per-row Server Actions that call `updateTag(`posts:author:${author}`)` for granular invalidation
- Combine `useTransition` with `<Suspense>` for non-blocking optimistic UI on the new post form[^23]
- Move the in-memory store behind a Redis instance and re-use the same `'use cache'` API — the directive doesn't care about the underlying datasource, only the function's arguments

If you're still migrating off the Pages Router, the [Next.js Pages Router to App Router migration tutorial](https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial) covers async params, codemods, and the layout traps that bite during the move. For the wider RSC mental model — what Server Components actually compile down to and why they ship zero JS — see [React Server Components: the future of seamless rendering](https://nerdleveltech.com/react-server-components-the-future-of-seamless-rendering).

---

## Related reads

- [Build an Accessible React Combobox with React Aria (2026)](https://nerdleveltech.com/build-accessible-react-combobox-react-aria-components-tutorial)

[^1]: Next.js, "Guides: Streaming." https://nextjs.org/docs/app/guides/streaming
[^2]: Next.js, "Next.js 16" (release blog post, 2025-10-21). https://nextjs.org/blog/next-16
[^3]: Next.js, "Upgrading: Version 16." https://nextjs.org/docs/app/guides/upgrading/version-16
[^4]: TypeScript releases on npm — `npm view typescript version` returned 6.0.3 (published 2026-04-16) at the time of writing; 5.7.x is still supported. https://www.npmjs.com/package/typescript
[^5]: `npm view next time --json` — `16.2.6` published 2026-05-07T19:01:54.751Z. https://www.npmjs.com/package/next
[^6]: Next.js, "Minimum React Version." https://nextjs.org/docs/messages/react-version
[^7]: Next.js, "next.config.js: cacheComponents." https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents
[^8]: Next.js, "Functions: cacheLife." https://nextjs.org/docs/app/api-reference/functions/cacheLife
[^9]: Next.js, "Dynamic APIs are Asynchronous." https://nextjs.org/docs/messages/sync-dynamic-apis
[^10]: Next.js, "Upgrading: Version 16 — Automated upgrade." https://nextjs.org/docs/app/guides/upgrading/version-16
[^11]: Next.js, "File-system conventions: loading.js." https://nextjs.org/docs/app/api-reference/file-conventions/loading
[^12]: Next.js, "App Router: Streaming." https://nextjs.org/learn/dashboard-app/streaming
[^13]: React, "<Suspense> — Reference." https://react.dev/reference/react/Suspense
[^14]: Next.js, "Directives: use cache." https://nextjs.org/docs/app/api-reference/directives/use-cache
[^15]: Next.js, "Functions: cacheLife — Default profiles." https://nextjs.org/docs/app/api-reference/functions/cacheLife
[^16]: Next.js 16.2 blog post — "use cache is now stable." https://nextjs.org/blog/next-16-2
[^17]: Next.js, "Functions: updateTag." https://nextjs.org/docs/app/api-reference/functions/updateTag
[^18]: Next.js, "Functions: revalidateTag." https://nextjs.org/docs/app/api-reference/functions/revalidateTag
[^19]: Next.js, "Getting Started: Revalidating." https://nextjs.org/docs/app/getting-started/revalidating
[^20]: Next.js, "Guides: Streaming — Proxy buffering caveats." https://nextjs.org/docs/app/guides/streaming
[^21]: Next.js, "Getting Started: Server and Client Components." https://nextjs.org/docs/app/getting-started/server-and-client-components
[^22]: Next.js GitHub Discussion #58865 — "Suspense And Loading" (proxy/buffering thread). https://github.com/vercel/next.js/discussions/58865
[^23]: React, "useTransition — Reference." https://react.dev/reference/react/useTransition
