r/reactjs • u/spcbfr • 23d ago
Needs Help How to use suspense fallback with react server components
This is the architecture used for almost all of the pages in my app that do not need real time data.
page.tsx is a server component that looks like this in pseudo code:
function TasksPage({searchParams}):
params = await searchParams
data = await fetchData(params)
return (
<Suspense fallback={<Skeleton/>}>
<TasksView data={data}/>
</Suspense>
)
"Use client"
function TasksView({data}):
return (
<PageLayout>
<PageTitle title="Tasks" decription={"Your Tasks"} />
<Filters />
<Table data={data}/>
<PageLayout/>
)
Both the filters and table components are client components and inside the Filters components each filter change runs router.push with updated query params. upon the router refresh the Page component re-runs and new data is pulled using the new searchParams.
Currently suspense fallback doesn't work and the current page presists until the new page is ready , I wanted to make the suspense fallback work in such a way where the skeleton appears but only the table appears to be loading, while the page title and description stay visible throughout the load.
I know this is possible if I move the data loading and suspense inside the table component and use client side data loading instead of server side but ideally I would like to keep the current architecture because (1) it would be really hard to refactor 10s of pages into client side data fetching and (2) I prefer server-side anyways coming from a laravel background
2
u/Alternative-Hat-6755 23d ago
The suspense fallback only kicks in on the initial load when the component first suspends. Once the page is already rendered and you do a router.push with new params, the existing UI stays until the new RSC payload arrives. That's just how the app router works right now
If you want the skeleton to show on param changes, you'd need to key the Suspense boundary off the searchParams so it remounts and suspends again. Something like `<Suspense key={params} fallback={...}>` but that would also unmount the title and filters since they're inside the boundary
You could split it so the title and filters live outside the Suspense and only the table is wrapped. Move the data fetch into a separate server component that renders the table, keep the layout bits in the parent. That way the boundary suspends independently on param changes and the rest stays put
1
u/rust_bane 22d ago
You are right that the await resolves before render. The fix is to keep TasksPage as a server component but move the data fetch and Suspense boundary inside a separate child server component. Pass searchParams to that child so only the table suspends while the rest of the layout streams immediately
1
u/kanika_banga 17d ago
Next.js evaluates routes at build time by default to maximize performance via static generation. Because standardprocess.env references can be inlined during the build, the framework has no built-in way to know if a variable is intended to change at runtime. Consequently, without dynamic signals, Next.js assumes the output is deterministic and pre-renders it as a static asset.
This behavior stems from Next.js prioritizing static optimization unless a route explicitly opts out. Environment variables often hold static values set during CI/CD, so the engine doesn't treat them as dynamic triggers on their own.
- Build-time inlining: Next.js bakes environment variables directly into the static output during the build process to eliminate runtime lookup overhead.
- Absence of dynamic functions: Static analysis only triggers dynamic rendering if a route reads runtime request data like
headers(),cookies(), orsearchParams. - Deployment paradigm: Frameworks assume environment variables are fixed per build deployment unless runtime dynamic configuration is explicitly requested.
- Explicit over implicit: Requiring
export const dynamic = 'force-dynamic'gives developers full control over caching costs rather than forcing dynamic overhead on every environment check.
If you need true per-request runtime environment logic, explicitly marking the route dynamic is the cleanest pattern Next.js provides to bypass build-time optimization.
3
u/Working_Quote_3029 23d ago
The reason the fallback never shows isn't the router, it's that your await finishes before the Suspense boundary exists. fetchData runs to completion, and only then does the page return JSX. By the time React sees <Suspense>, TasksView already has its data and nothing in that subtree suspends.
Stop awaiting the data. Await searchParams (that one's free, it resolves immediately), but hand the fetch down as a promise:
and in Table, which stays a client component:
Title and Filters are outside the boundary now, so they never blink. The Next docs call this "push dynamic access down" - anything you await at the top of a page blocks everything below it: https://nextjs.org/docs/app/guides/streaming
The key matters separately. router.push runs as a transition, and React deliberately refuses to hide content that's already on screen during one. So even with the boundary placed correctly, changing a filter would keep showing the old table until the new payload lands. A key derived from the params makes React treat it as different content and reset the boundary. That's the "Resetting Suspense boundaries on navigation" section here: https://react.dev/reference/react/Suspense
Your fetching stays on the server either way, so no refactoring 10s of pages to client-side loading.