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.
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 suspends | Trigger | What the boundary is for |
|---|---|---|
| Lazily-loaded code | React.lazy() or next/dynamic | The chunk is still in flight |
| A component reading a pending promise | use(promise), or an async Server Component being awaited | The data is still in flight |
Not an event handler's await | — | Nothing suspends; use component state |
Not a useEffect fetch | — | Nothing 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 type | Count | What it means |
|---|---|---|
○ Static | 27 | Prerendered at build time |
● SSG | 3 | Dynamic segments, prerendered via generateStaticParams |
ƒ Dynamic | 8 | Server-rendered per request |
| Total route entries | 38 | Producing 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
useSearchParamswill cause the Client Component tree up to the closestSuspenseboundary 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
| Mistake | Symptom | Fix |
|---|---|---|
Wrapping a useEffect fetch in <Suspense> | The fallback never renders; the boundary is inert | Nothing suspends there — use a loading state, or move the fetch to a Server Component |
| One boundary around the whole page | The entire page is replaced by a skeleton for as long as the slowest query takes | Put the boundary around the awaiting subtree only |
| Adding boundaries to a fully static route | No measurable change; extra components in the tree | Check the route's marker in next build first — ○ and ● have nothing to stream |
Assuming useSearchParams fails the build | Chasing a build error that never appears | It 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 content | Layout shift when the real content swaps in | Size the fallback to the content; this site measures CLS 0 on every page |
<Suspense> around a Client Component that imports a heavy module | The module still loads eagerly | Suspense 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.
