skip to content

A Next.js App Router dashboard layout fetches an unread-messages count and renders it in the sidebar. Users report the number is right on a hard refresh but never changes as they click between pages under that dashboard. Why, and what are your options?

level: seniorimportance: should knowfreq 42%

answer

  1. only changed segments re-render
  2. fine after refresh, frozen while clicking
  3. the shell was never asked again
  4. who changes the value, and how often
  5. remounting the shell is the wrong lever

basics

~20 s

Shared layouts are preserved across client navigation: Next re-renders only the segments that changed, so a layout above the changed page never re-runs and the value it fetched on first load stays frozen until a full load or an explicit refresh.

solid answer

~50 s

This is partial rendering working as designed. Moving from `/dashboard/inbox` to `/dashboard/settings` changes the page segment, not the layout above it, so Next requests only the changed segments and the dashboard layout is kept exactly as it is — its server render, and therefore its fetch, does not repeat. A hard refresh re-renders the whole route, which is why the number looks correct then. The options, in the order I would consider them: move the counter into the page segment if every page under the dashboard genuinely needs it fresh; render it in a client component that refetches on its own schedule or subscribes to updates; or, when the count changes as a result of a user action, have that action invalidate the layout's data and trigger a refresh of the current route so the shell re-renders. What I would not do is wrap the shell in a template just to force a remount — that rebuilds the whole subtree on every navigation to fix one number.

code

tsx · 12 lines
tsx
import type { ReactNode } from 'react';
import { getUnreadCount } from '@/lib/messages';

export default async function DashboardLayout({ children }: { children: ReactNode }) {
  const unread = await getUnreadCount();
  return (
    <div className="shell">
      <aside>Unread: {unread}</aside>
      <main>{children}</main>
    </div>
  );
}

go deeper

for a junior

Recall that a layout shared by two routes stays mounted when you navigate between them, so anything it computed on first render does not automatically update.

for a middle

Explain partial rendering: Next requests only the segments that differ between the two routes, so the shared layout's server render is skipped. Be able to say why a hard refresh looks correct.

for a senior

Pick the fix from the requirement — move the data into the page, let a client component own its refresh, or invalidate and refresh on the mutation that changed it — and be able to argue why forcing a remount of the shell is the wrong lever.

for a principal

Decide where cross-cutting live values belong across a large app: a shared shell that renders once is a poor home for anything volatile. Set the pattern so teams do not each invent their own polling or remount workaround.

## The mechanism On client-side navigation the App Router does **partial rendering**: it compares the segment tree of the route you are leaving with the one you are entering, and asks the server only for the segments that actually differ. Everything shared between the two routes stays mounted and untouched. That is the property that makes App Router navigation feel cheap — the sidebar does not flash, its scroll position holds, and no work is repeated. The same property is why the counter freezes. `app/dashboard/layout.tsx` is shared by `/dashboard/inbox` and `/dashboard/settings`. When the user moves between them the layout is not in the changed set, so its component function is not called again and the `await` inside it never runs a second time. The value rendered into that sidebar is the value from whenever the layout last rendered — first load, or the last full document request. ```tsx // app/dashboard/layout.tsx — renders once, then persists import type { ReactNode } from 'react'; export default async function DashboardLayout({ children }: { children: ReactNode }) { const unread = await getUnreadCount(); return ( <div> <aside>Unread: {unread}</aside> <main>{children}</main> </div> ); } ``` A hard refresh renders the whole route from the root down, so every layout runs again and the number is correct — which is exactly the reporting pattern that makes this confusing to debug. "Right after F5, stale after clicking around" is the fingerprint of data owned by a persistent segment. ## What actually causes a layout to render again It helps to name the events, because the fix follows from them: - A **full document load** — first visit, refresh, or arriving from outside the app. - **Navigating to a route that does not share the layout** and then back, which unmounts and remounts it. - An explicit **refresh of the current route**, which re-renders the matched segments including the layouts above the page. - **Invalidating the data the layout depends on**, typically as part of the mutation that changed it. There is no event corresponding to "the user clicked a sibling link", and that is deliberate. ## Choosing a fix **Move the data to where the re-render happens.** If the freshness requirement is real on every view, the counter belongs in the page segment, not above it. This is the cheapest correct answer and it is often refused for the wrong reason — "but then I repeat it in five pages". Repeating a small server component in five pages is not the problem; freezing a number in the shell is. **Make it a client component that owns its own updates.** A badge that polls, or subscribes to a socket or an event stream, does not care about the segment tree at all. This is the right shape when the value can change without the user doing anything — another person sent the message. **Invalidate on mutation.** When the count only moves because of something the user did in this app, the mutation is the natural trigger: it invalidates the data the shell reads and refreshes the current route so the layout renders again with the new value. This keeps the layout's simple server-fetch shape and pays the cost only when something actually changed. **What not to do.** Converting the shell to a `template.tsx` does force a fresh render per navigation, and it is the wrong trade: every navigation under that segment now remounts the entire subtree, discarding DOM state, restarting effects, and repeating all of the shell's work — an expensive global change to fix one field. Equally, disabling the framework's navigation behaviour wholesale to make one number live is a symptom-level fix that costs you the thing you adopted the App Router for. ## The senior signal Two things separate a strong answer. First, naming the mechanism rather than the symptom: shared segments are preserved, only changed segments re-render, and the layout is not in the changed set. Second, choosing among the fixes by asking who changes the value and how fresh it must be — a value that changes only through this user's actions, a value that changes from outside, and a value that must be exact per view all point at different answers. A candidate who jumps straight to "force a remount" has fixed the number and made every navigation more expensive.

  • What events do cause that dashboard layout to render again?
    A full document load — first visit, refresh, or entry from outside the app; navigating to a route that does not share the layout and coming back, which unmounts and remounts it; an explicit refresh of the current route, which re-renders the matched segments including the layouts above the page; and invalidating the data the layout reads, usually as part of the mutation that changed it.
  • Would putting the counter in a template.tsx instead of the layout fix it?
    It would make the number update, and it is the wrong trade. A template is remounted on every navigation beneath it, so the entire subtree is rebuilt, effects restart, DOM state is discarded and all of the shell's work repeats — on every click — to keep one field current. Move the data down, or let a client component own its own refresh.
  • How would you confirm the diagnosis before changing anything?
    Reproduce the split: hard refresh shows the correct value, clicking between sibling routes does not change it. Then log inside the layout's server render and watch it fire on the refresh but not on the navigations, and inspect the network requests for those navigations — they carry only the changed segments. That pins it on partial rendering rather than on the data source.
  • The same layout also needs to highlight the currently active nav link. Does this issue affect that too?
    Yes, in the same way — a server-rendered highlight computed when the layout last ran would not follow the user. Because the shell persists, anything that must track the current route belongs in a Client Component inside the layout, which re-renders on navigation, rather than being computed once during the layout's server render.

saying these in an interview costs you the question

  • Layouts re-render on every navigation, so it must be caching
  • Convert the layout to a template so it remounts
  • The fetch is broken; add a cache-busting query string
  • Layouts receive searchParams, so read the route from there
  • Nothing can update a layout short of a full page reload

context