Skip to content

Worked review 2: a dashboard with login, streaming charts, and a settings form

The prompt was: “Build a dashboard behind login, with a streamed chart and a settings form the user can save.” This project already has cacheComponents: true in next.config.ts — the AI assistant was told that, and mostly wrote for it, but it still reaches for a few patterns straight out of its pre-16 training data. Read it in the same reading order as the first review: boundaries and rendering first, then the caching model, then config. It has 6 real bugs, and next build only catches two of them.

// middleware.ts — as generated
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { randomUUID } from 'node:crypto'
export const config = { runtime: 'edge' }
export function middleware(request: NextRequest) {
const requestId = randomUUID()
const response = NextResponse.next()
response.headers.set('x-request-id', requestId)
return response
}
// next.config.ts — as generated
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
// app/dashboard/page.tsx — as generated
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { Chart } from './chart'
import { SettingsForm } from './settings-form'
import { LastRefreshed } from './last-refreshed'
import { AnalyticsWidget } from './analytics-widget'
export const revalidate = 60
export default async function DashboardPage() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return (
<main>
<h1>Dashboard</h1>
<p>theme: {theme}</p>
<Suspense fallback={<p>Loading chart…</p>}>
<Chart />
</Suspense>
<LastRefreshed />
<SettingsForm />
<AnalyticsWidget />
</main>
)
}
// app/dashboard/chart.tsx — as generated
async function getChartData() {
await new Promise((r) => setTimeout(r, 50))
return { points: [3, 7, 5, 9] }
}
export async function Chart() {
const data = await getChartData()
return <p>chart points: {data.points.join(',')}</p>
}
// app/dashboard/settings-form.tsx — as generated
'use client'
import { useFormState } from 'react-dom'
import { saveSettings } from './actions'
export function SettingsForm() {
const [state, formAction] = useFormState(saveSettings, { ok: false, name: '' })
return (
<form action={formAction}>
<input name="name" defaultValue={state.name} />
<button type="submit">Save</button>
{state.ok && state.name && <p>Saved {state.name}</p>}
</form>
)
}
// app/dashboard/actions.ts — as generated
'use server'
export async function saveSettings(prevState: unknown, formData: FormData) {
const name = String(formData.get('name') ?? '')
return { ok: true, name }
}
// app/dashboard/last-refreshed.tsx — as generated
'use client'
export function LastRefreshed() {
return <p>Last refreshed: {Date.now()}</p>
}
// app/dashboard/analytics-widget.tsx — as generated
'use client'
export function AnalyticsWidget() {
return <div data-analytics-id={process.env.NEXT_PUBLIC_ANALYTICS_KEY} />
}

Boundaries and rendering: Date.now() read directly in a Client Component’s render

Section titled “Boundaries and rendering: Date.now() read directly in a Client Component’s render”

LastRefreshed prints Date.now() straight into JSX.

🐛 Spot the bug

'use client'
export function LastRefreshed() {
return <p>Last refreshed: {Date.now()}</p>
}
Show the answer

Every Client Component renders twice: once on the server for the initial HTML, once again in the browser during hydration. Date.now() has millisecond resolution, so the two renders almost never agree. Loading this exact component in a real browser against next dev produces React 19’s own hydration-mismatch error, with the actual two values it disagreed on:

Error: Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
...
<Page params={Promise} searchParams={Promise}>
<p>
+ 1790318503755
- 1790318503711

The fix is the same one the static and dynamic rendering lesson proves: useSyncExternalStore with a fixed getServerSnapshot, so the SSR render and the hydration render agree on a placeholder by construction, and the real clock value only appears after hydration finishes.

Boundaries and rendering: useFormState instead of useActionState

Section titled “Boundaries and rendering: useFormState instead of useActionState”

SettingsForm imports useFormState from react-dom.

🐛 Spot the bug

'use client'
import { useFormState } from 'react-dom'
import { saveSettings } from './actions'
export function SettingsForm() {
const [state, formAction] = useFormState(saveSettings, { ok: false, name: '' })
return (
<form action={formAction}>
<input name="name" defaultValue={state.name} />
<button type="submit">Save</button>
{state.ok && state.name && <p>Saved {state.name}</p>}
</form>
)
}
Show the answer

useFormState was React 18’s canary-era name; React 19 renamed it useActionState and moved it to the react package. react-dom still exports useFormState for backward compatibility, but it logs a real console error, naming the exact component, the moment it runs — captured verbatim against React 19.2.8 in a real browser:

[ERROR] ReactDOM.useFormState has been renamed to React.useActionState. Please update %s to use React.useActionState. SettingsForm

The build succeeds either way — this is a runtime-only signal, invisible to next build and tsc. The fix is import { useActionState } from 'react' in place of useFormState from react-dom; the call signature is unchanged.

Caching model: cookies() read above the <Suspense> boundary

Section titled “Caching model: cookies() read above the <Suspense> boundary”

next.config.ts has cacheComponents: true. DashboardPage reads cookies() at the top of the function, before returning any JSX — including the <Suspense> boundary around Chart.

🐛 Spot the bug

import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { Chart } from './chart'
export default async function DashboardPage() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return (
<main>
<p>theme: {theme}</p>
<Suspense fallback={<p>Loading chart…</p>}>
<Chart />
</Suspense>
</main>
)
}
Show the answer

Under Cache Components, a request-time API has to sit inside a <Suspense> boundary — the framework needs to know exactly which part of the tree is allowed to wait for request data so it can prerender everything else as a static shell. cookies() here runs before the function returns any JSX at all, so there’s no boundary around it yet. Building this exact page fails outright, not with a vague warning:

Error: Route "/dashboard": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
- [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
- [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
- [block] Set `export const instant = false` to allow a blocking route

The fix is to push the cookies() read down into its own small async component and wrap that in its own <Suspense>, exactly the way a slow await gets its own boundary in the streaming and Suspense lesson.

Caching model: a Route Segment Config export next to cacheComponents

Section titled “Caching model: a Route Segment Config export next to cacheComponents”

app/dashboard/page.tsx also has export const revalidate = 60 sitting at the top of the file.

🐛 Spot the bug

export const revalidate = 60
export default async function DashboardPage() {
// ...
}
Show the answer

Route Segment Config (dynamic, revalidate, fetchCache, dynamicParams, runtime) is the previous caching model. It is mutually exclusive with Cache Components — every one of those exports fails the build the instant cacheComponents: true is set, regardless of the value:

Error: Turbopack build failed with 1 error:
./app/dashboard/page.tsx:2:14
Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.

There is no direct replacement for a page-level revalidate under Cache Components — the equivalent is wrapping the specific cached work in 'use cache' with a cacheLife profile, exactly at the granularity that actually needs it, as the route segment config lesson’s full mapping table shows.

Config: middleware.ts assuming the Edge runtime

Section titled “Config: middleware.ts assuming the Edge runtime”

The project has a middleware.ts (not proxy.ts) that reads randomUUID from node:crypto and opts into the Edge runtime.

🐛 Spot the bug

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { randomUUID } from 'node:crypto'
export const config = { runtime: 'edge' }
export function middleware(request: NextRequest) {
const requestId = randomUUID()
const response = NextResponse.next()
response.headers.set('x-request-id', requestId)
return response
}
Show the answer

Three separate, real signals fire on this file, in order. First, the filename itself:

⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
To migrate automatically, run:
npx @next/codemod@canary middleware-to-proxy .

Second, the Edge runtime genuinely cannot load a Node.js built-in:

Warning: A Node.js module is loaded ('node:crypto' at line 3) which is not supported in the Edge Runtime.

Third, the build fails outright, because Edge itself is deprecated for rendering:

Error: Page /middleware provided runtime 'edge', the edge runtime for rendering is currently experimental. Use runtime 'experimental-edge' instead.

The fix is the file rename plus dropping both the runtime opt-in and the Node-only import: proxy.ts, no config.runtime, and NextResponse.next() alone is enough for a header-forwarding proxy — the default (and now only sensible) runtime is Node.js, exactly as the middleware and the request lifecycle lesson establishes.

AnalyticsWidget reads process.env.NEXT_PUBLIC_ANALYTICS_KEY — a name that sounds like the public analytics id every other lesson in this course uses as its example, but here it holds a live API key.

🐛 Spot the bug

'use client'
export function AnalyticsWidget() {
return <div data-analytics-id={process.env.NEXT_PUBLIC_ANALYTICS_KEY} />
}
Show the answer

The NEXT_PUBLIC_ prefix does exactly what it says regardless of what the variable is actually named — it tells Next.js to inline the value, as a literal, into the JavaScript shipped to every visitor’s browser. Setting NEXT_PUBLIC_ANALYTICS_KEY to a live-looking secret in .env.local and building this exact component, then grepping the emitted client chunk, finds the value sitting in plain text:

["default",0,function(){return(0,e.jsx)("div",{"data-key":"sk_live_51H8xNOTAREALSE...

Nothing here is a build error or even a warning — NEXT_PUBLIC_ values are inlined by design, and Next.js has no way to know that this particular one is a real credential rather than a harmless public id. The fix is to never prefix a value that must stay secret; a real API key belongs in an unprefixed server-only variable (ANALYTICS_SECRET_KEY), read only inside a Server Component, a Server Action, or a Route Handler, exactly as the environment and configuration lesson proves for the reverse case (a genuinely public value accessed the wrong way).

proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// FIX (config bug): current file name (proxy.ts), default Node.js runtime —
// no runtime export, no Edge opt-in, no Node-only import that Edge would
// reject.
export function proxy(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
app/dashboard/theme-badge.tsx
import { cookies } from 'next/headers'
// FIX (caching bug): the cookies() read now lives inside the component that
// a <Suspense> boundary wraps, not above it.
export async function ThemeBadge() {
const theme = (await cookies()).get('theme')?.value ?? 'light'
return <p>theme: {theme}</p>
}
app/dashboard/chart.tsx
import { cacheLife } from 'next/cache'
async function getChartData() {
'use cache'
cacheLife('minutes')
await new Promise((r) => setTimeout(r, 50))
return { points: [3, 7, 5, 9] }
}
// FIX (segment config bug): no route segment config export anywhere in this
// project — caching is expressed with 'use cache' + cacheLife instead.
export async function Chart() {
const data = await getChartData()
return <p>chart points: {data.points.join(',')}</p>
}
app/dashboard/actions.ts
'use server'
export async function saveSettings(prevState: unknown, formData: FormData) {
const name = String(formData.get('name') ?? '')
return { ok: true, name }
}
app/dashboard/settings-form.tsx
'use client'
// FIX (useFormState bug): useActionState from 'react', not useFormState from
// 'react-dom'.
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveSettings } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending} type="submit">{pending ? 'Saving...' : 'Save'}</button>
}
export function SettingsForm() {
const [state, formAction] = useActionState(saveSettings, { ok: false, name: '' })
return (
<form action={formAction}>
<input name="name" defaultValue={state.name} />
<SubmitButton />
{state.ok && state.name && <p>Saved {state.name}</p>}
</form>
)
}
app/dashboard/last-refreshed.tsx
'use client'
// FIX (hydration bug): useSyncExternalStore with a deterministic
// getServerSnapshot — the SSR render and the hydration render both see 0, so
// there is nothing to mismatch; the real clock value only appears after
// hydration finishes.
import { useSyncExternalStore } from 'react'
function subscribe() {
return () => {}
}
function getSnapshot() {
return Date.now()
}
function getServerSnapshot() {
return 0
}
export function LastRefreshed() {
const ts = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return <p>Last refreshed: {ts === 0 ? '—' : new Date(ts).toLocaleTimeString()}</p>
}
app/dashboard/analytics-widget.tsx
'use client'
// FIX (secret bug): only a genuinely public analytics id carries the
// NEXT_PUBLIC_ prefix; the real secret stays server-only.
export function AnalyticsWidget() {
return <div data-analytics-id={process.env.NEXT_PUBLIC_ANALYTICS_ID} />
}
app/dashboard/page.tsx
import { Suspense } from 'react'
import { ThemeBadge } from './theme-badge'
import { Chart } from './chart'
import { SettingsForm } from './settings-form'
import { LastRefreshed } from './last-refreshed'
import { AnalyticsWidget } from './analytics-widget'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading theme…</p>}>
<ThemeBadge />
</Suspense>
<Suspense fallback={<p>Loading chart…</p>}>
<Chart />
</Suspense>
<LastRefreshed />
<SettingsForm />
<AnalyticsWidget />
</main>
)
}

Built clean against Next.js 16.3.6 with cacheComponents: true — next build reports 0 errors, 0 warnings, and lists /dashboard as a Partial Prerender with the 'minutes' cache profile’s real numbers:

Route (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
└ ◐ /dashboard 1m 1h
ƒ Proxy (Middleware)

Starting the built server and requesting /dashboard confirms every fix end to end — theme: light (the fixed cookies() read inside its own <Suspense>), chart points: 3,7,5,9 (a 'use cache' value, not a route segment config), the real analytics id present in the client bundle, and the real secret genuinely absent from every static chunk:

theme: light
chart points: 3,7,5,9
grep for the secret value across .next/static/chunks/ -> no matches
grep for NEXT_PUBLIC_ANALYTICS_ID's value across .next/static/chunks/ -> 1 match (expected — it's meant to be public)
#BugCategoryFix
1Date.now() read directly in a Client Component’s renderBoundaries/renderinguseSyncExternalStore with a fixed getServerSnapshot
2useFormState from react-domBoundaries/renderinguseActionState from react
3cookies() read above the <Suspense> boundaryCaching modelPush the read into its own component, inside its own <Suspense>
4export const revalidate = 60 next to cacheComponents: trueCaching modelRemove it; use 'use cache' + cacheLife at the granularity that needs it
5middleware.ts with runtime: 'edge' and a node:crypto importConfigRename to proxy.ts, drop the runtime export and the Node-only import
6A live API key named NEXT_PUBLIC_ANALYTICS_KEYConfigNever prefix a secret; keep it in an unprefixed, server-only variable

This review’s bugs split cleanly into two kinds, and the split tracks exactly what tooling can see. cookies() above <Suspense> and the segment-config-next-to-cacheComponents bug are both build-time facts — next build already knows the caching model this project runs and rejects code written for the other one. Date.now() in render, useFormState, the Edge-runtime assumption, and the leaked secret are all runtime facts: the code is syntactically valid Next.js either way, and only running it — a real browser, a real request, a real grep through the built output — tells you which one you actually have.

  • “cacheComponents: true in next.config.ts means every file in the project already writes for it.” This exact project has the flag on and still shipped a Route Segment Config export and a middleware.ts written like a pre-16 tutorial — the flag doesn’t retroactively fix code written against the old model.
  • “A variable name like NEXT_PUBLIC_ANALYTICS_KEY is fine because it sounds like the public id every example uses.” The prefix inlines whatever the variable actually holds, regardless of what the rest of its name implies.
  • “useFormState still works, so it’s not really a bug.” It runs, but React 19 logs a real error naming the exact broken component — treating “it doesn’t crash” as “it’s fine” misses a signal the framework is actively trying to give you.
flowchart TD
  A["1. Boundaries and rendering"] --> A1["Date.now() in a Client Component's render"]
  A --> A2["useFormState instead of useActionState"]
  B["2. Caching model"] --> B1["cookies() above the Suspense boundary"]
  B --> B2["revalidate export next to cacheComponents"]
  C["5. Config"] --> C1["middleware.ts assuming the Edge runtime"]
  C --> C2["a real secret named NEXT_PUBLIC_..."]
Where each bug sits in the reading order
What happens when this Client Component is server-rendered and then hydrates in the browser?
'use client'
export function LastRefreshed() {
  return <p>Last refreshed: {Date.now()}</p>
}
What is the real, observable difference between useFormState (react-dom) and useActionState (react) in React 19.2.8?
With cacheComponents: true, why does this fail to build?
export default async function DashboardPage() {
  const theme = (await cookies()).get('theme')?.value
  return (
    <main>
      <Suspense fallback={<p>...</p>}>
        <Chart />
      </Suspense>
    </main>
  )
}
What happens when this project runs next build?
// next.config.ts
const nextConfig = { cacheComponents: true }
// app/dashboard/page.tsx
export const revalidate = 60
A project has middleware.ts (not proxy.ts) with export const config = { runtime: 'edge' } and an import { randomUUID } from 'node:crypto'. Which of these actually happens when it builds?
An analytics widget reads process.env.NEXT_PUBLIC_ANALYTICS_KEY, and that variable actually holds a live, sensitive API key. What does next build do about it?