Skip to main content
A dashboard typically renders four or five independent metrics. If you await them all at the page level, the whole page waits for the slowest one. Server Components plus Suspense let each tile stream in on its own schedule — fast metrics paint immediately, the percentile query keeps spinning until it’s ready. Esix features used: count, sum, average, percentile. React features used: <Suspense> boundaries, async server components.

What You’ll Build

An admin dashboard with four tiles:
  • Orders today
  • Revenue today
  • Average order value
  • p95 fulfillment time
Each tile is its own async server component, wrapped in its own Suspense boundary, with its own fallback skeleton.

The Order Model

One Component Per Metric

Each tile owns its own query. Keeping them separate is the whole point — it’s what lets Suspense stream them in independently:
A couple of patterns worth flagging:
  • todayOrders() is a factory. It returns a fresh QueryBuilder each time. You can’t reuse a single builder across count() and sum() — every terminal call needs its own builder.
  • Filter before percentile. Including rows where fulfilledAt is null would skew the distribution. The chained where('fulfilledAt', '!=', null) fixes that.
  • Aggregates return 0 on empty results. No null-handling needed even on a brand-new day.

The Dashboard Page

Wrap each tile in its own Suspense boundary. The page itself doesn’t await anything — Next.js streams the fallback skeletons first, then swaps in each tile as its query resolves:
export const dynamic = 'force-dynamic' is the right choice here — a dashboard should never be cached.

Why Streaming Beats Promise.all

You could write this with a single page-level await Promise.all([...]) and hand every metric to one tile component. It would render correctly, but the user wouldn’t see anything until the slowest query finished. With one Suspense boundary per tile, fast metrics appear right away and slow ones catch up. Same total work, much better perceived performance.

Pattern Notes

  • One boundary per tile. Boundaries are what unblocks streaming — without them, an await further down still blocks the page shell.
  • Don’t pre-await in the parent. Once the parent component is async and blocks on data, you’ve lost streaming for its whole subtree.
  • Watch out for caching. Default fetch caching doesn’t apply to direct database calls, but Next.js’s unstable_cache does. Wrap each metric in unstable_cache with a short TTL if you want to share results across requests.

What’s Next