Skip to main content
ASoc
Tutorial

React Suspense: 376 Prerendered Pages, Zero Boundaries

Suspense buys streaming, and a page rendered at build time has nothing left to stream. Zero boundaries and zero loading.tsx here, plus the useSearchParams de-opt everyone reports wrong.

The ASoc Team9 min read

React Suspense is a boundary that lets a component tell React "I am not ready yet" and shows a fallback until it is. This codebase has zero <Suspense> boundaries and zero loading.tsx files across 376 prerendered pages — not an oversight. Suspense buys streaming, and a page rendered at build time has nothing left to stream.

What Suspense in React actually is

Suspense is not a data-fetching library, a loading-spinner component, or a performance feature you sprinkle on. It is one thing: a boundary that catches a suspended child and renders fallback in its place until the child can complete.

<Suspense fallback={<Skeleton />}>
  <SomethingThatSuspends />
</Suspense>

Only two things suspend, and conflating them is where most confusion starts:

What suspendsTriggerWhat the boundary is for
Lazily-loaded codeReact.lazy() or next/dynamicThe chunk is still in flight
A component reading a pending promiseuse(promise), or an async Server Component being awaitedThe data is still in flight
Not an event handler's awaitNothing suspends; use component state
Not a useEffect fetchNothing suspends; the effect runs after paint

That last pair matters more than it looks. A component that fetches inside useEffect and flips setLoading(false) never suspends, so wrapping it in Suspense does exactly nothing — the boundary never activates, and the fallback never renders. This is the single most common mistake in the "how to use React Suspense" genre.

Why 376 prerendered pages need zero boundaries

Here is this site's route table, from a build run while writing this post:

Route typeCountWhat it means
Static27Prerendered at build time
SSG3Dynamic segments, prerendered via generateStaticParams
ƒ Dynamic8Server-rendered per request
Total route entries38Producing 376 prerendered pages

The three entries are /blog/[slug] (114 posts), /templates/[slug] (111 products) and the blog's per-post OG image route — which is why 38 route entries expand into 376 pages.

Now apply the definition. On a prerendered route the HTML is already complete on disk before a visitor arrives. There is no promise in flight, so nothing can suspend, so a boundary has nothing to catch. Adding <Suspense> to /pricing would not make it faster; it would add a component that never activates.

The eight ƒ routes are the interesting ones, and they are all account surfaces:

ƒ /api/download          ƒ /dashboard           ƒ /login
ƒ /api/webhooks/lemonsqueezy  ƒ /dashboard/settings  ƒ /reset-password
ƒ /auth/callback         ƒ /signup

Two of those (/api/*) are route handlers with no UI at all. The rest render behind an auth check. So the honest count of pages on this site where Suspense could do real work is small, and it is not the marketing pages that carry the traffic.

The one place Next.js would have pushed a boundary on us

There is a documented de-opt worth knowing precisely, because it is usually reported wrong. From the useSearchParams reference in the installed Next.js 16 docs:

If a route is prerendered, calling useSearchParams will cause the Client Component tree up to the closest Suspense boundary to be client-side rendered.

Note what that says and does not say. It is not a build failure — it is a rendering de-opt. Everything from useSearchParams up to the nearest boundary drops out of the prerendered HTML and renders on the client instead. With no boundary anywhere, "up to the closest boundary" means the whole page.

This codebase has zero useSearchParams calls, which is why it has never met that de-opt. The catalogue filters on /templates are local component state instead:

// src/components/organisms/TemplatesExplorer.tsx
"use client";
import { useState } from "react";
import { filterTemplates, type TemplateFilters } from "@/lib/filterTemplates";

That is a real trade-off, not a free win, and it is worth stating plainly: filter state lives in React and not in the URL, so a filtered view of the catalogue cannot be linked or shared, and the back button does not step through filter changes. What it buys is that /templates stays a static route whose first card is in the prerendered HTML — the card that is the page's LCP element, and which we already had to mark priority after finding a lazy-loaded LCP image costing 1.67 s of load delay.

If that trade ever flips — and for a storefront with 111 products, shareable filtered URLs are a reasonable thing to want — the fix is the documented one: move the useSearchParams reader into its own leaf component and wrap that leaf, not the page, in a boundary.

A React Suspense example that would actually earn its keep here

/dashboard is a Server Component that awaits Supabase before it can render anything:

// src/app/dashboard/page.tsx
import { createClient } from "@/lib/supabase/server";
import { computeAggregatedDownloads } from "@/lib/dashboardDownloads";

Today the whole page waits on those queries. The version with a boundary splits the shell from the slow part:

export default async function DashboardPage() {
  return (
    <>
      <DashboardHeader />           {/* renders immediately */}
      <Suspense fallback={<PurchasesSkeleton />}>
        <Purchases />               {/* awaits Supabase; streams in later */}
      </Suspense>
    </>
  );
}

async function Purchases() {
  const supabase = await createClient();
  const { data } = await supabase.from("orders").select("*");
  return <PurchaseList orders={data ?? []} />;
}

The rule that falls out of this: the boundary belongs around the thing that awaits, not around the page. Wrapping the whole page in one boundary means the fallback covers everything and the visitor sees a skeleton where a header could have been.

In the App Router you can get the same boundary from the file system instead — a loading.tsx in a segment wraps that segment's page.js and children in a <Suspense> automatically. This codebase has none of those either, for the same reason: 30 of its 38 route entries have nothing to wait for.

What this codebase uses instead

Every loading state here is a pending flag from a form submission, not a suspended render:

// src/components/molecules/NewsletterForm.tsx
const [state, formAction, pending] = useActionState(subscribeToWaitlist, null);
// …
<button disabled={pending}>{pending ? "…" : "Subscribe"}</button>

useActionState hands back pending for the duration of the Server Action. Nothing suspends, so no boundary is involved, and a <Suspense> wrapper around this form would never render its fallback. The skeleton-loader audit covers the three loading states this codebase actually ships and why none of them is a shimmer.

Two neighbouring posts stop where this one starts. React lazy loading covers the code-splitting half — this codebase has zero React.lazy() calls too, and the explicit per-post MDX import map it uses instead. Server Components vs Client Components covers where the boundary between the two goes; this post is only about what happens when something behind that boundary is not ready yet.

Mistakes and how they show up

MistakeSymptomFix
Wrapping a useEffect fetch in <Suspense>The fallback never renders; the boundary is inertNothing suspends there — use a loading state, or move the fetch to a Server Component
One boundary around the whole pageThe entire page is replaced by a skeleton for as long as the slowest query takesPut the boundary around the awaiting subtree only
Adding boundaries to a fully static routeNo measurable change; extra components in the treeCheck the route's marker in next build first — and have nothing to stream
Assuming useSearchParams fails the buildChasing a build error that never appearsIt is a silent de-opt to client rendering, so audit the prerendered HTML, not the build log
A fallback of a different height than the contentLayout shift when the real content swaps inSize the fallback to the content; this site measures CLS 0 on every page
<Suspense> around a Client Component that imports a heavy moduleThe module still loads eagerlySuspense does not defer imports; next/dynamic or a dynamic import() does

Frequently asked questions

What is React Suspense, in one sentence? A boundary component that renders a fallback while a descendant is suspended — waiting on lazily-loaded code or on a promise it is reading — and swaps in the real content when that resolves.

How do I use React Suspense for data fetching? Give it something that actually suspends. In the App Router that means an async Server Component awaited inside the boundary, or a Client Component calling use(promise) on a promise created outside the render. A fetch inside useEffect will not suspend no matter where you put the boundary.

Do I need Suspense if my pages are statically generated? Almost certainly not. Prerendered HTML is complete before the request arrives, so there is nothing in flight for a boundary to catch. On this site that covers 30 of 38 route entries and all 376 prerendered pages, which is why the count of boundaries here is zero.

Is loading.tsx different from writing <Suspense> myself? Only in placement. loading.tsx wraps that segment's page and its children in a boundary for you, and it does not wrap the segment's own layout.tsx or error.tsx. Writing the boundary by hand lets you put it around one subtree instead of a whole route segment, which is usually what you want.

Does Suspense reduce my JavaScript bundle? No, on its own. It changes when a fallback is shown, not what is downloaded. Code only leaves the initial bundle when something splits it — next/dynamic, a dynamic import(), or a route boundary. This codebase's one deliberate deferral works that way: src/lib/supabase/lazyClient.ts dynamically imports the auth stack (68 KiB gzipped, 255 KiB parsed) and skips it entirely for signed-out visitors.

Templates in this post

ASoc Cover is an insurance marketing site built around a two-minute-quote hero and a claim-paid dashboard mock; ASoc Echo markets an AI support product with a live chat-widget preview and an analytics panel; ASoc Edge is an applied-AI agency site anchored by a control-room dashboard section. All three are static marketing pages — exactly the shape that needs no boundaries at all.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the code-splitting half of this question, read React lazy loading; for what the loading states here actually look like, read React skeleton loaders.

Keep reading

Tutorial12 min read

React Tabs: Six Requirements, and the Three Ours Was Missing

Our dashboard tabs had the roles and none of the wiring — no aria-controls, no tabpanel, no roving tabindex. The audit, why no scanner caught it, and the fix that shipped with this post.

Read more
Tutorial10 min read

React Toast Notifications: Five Live Regions That Announced Nothing

Zero toast libraries and five aria-live regions here — all five mounted with their first message, so none of them ever announced. The audit, and the atom that fixed it.

Read more
Tutorial9 min read

React Tooltip: When 38 aria-label Attributes Beat a Library

38 aria-label attributes, zero tooltip libraries. What this codebase actually uses to name icon-only buttons, and the one place a real tooltip earns its keep.

Read more