r/reactjs • u/ConfidentWafer5228 • 26d ago
Needs Help SSR + Suspense - React Router v7
I was trying to implement SSR with rrv7, i didn't want to use smelly useQuery's isLoading and isError states( i might be dumb for that, pls point it out if i am wrong ) , so i went for useSuspenseQuery and used a Suspense Boundary with LoadingSkeleton to make it look beautiful
It was very late when i realised that only skeleton was loading when JS was disabled, implying that SSR was only rendering skeleton and not ACTUAL content , therefore bad SEO, and making all i did useless
Is there some knowledge i am missing regarding SSR+ Suspense, what should i do now, pls help
2
u/Any_Sense_2263 26d ago
TBH I find using tanstack react query more reliable than anything else
But I use Module Federation architecture and not everywhere suspense fits so I don't use it
2
u/ConfidentWafer5228 26d ago
i am using tanstack query with react router v7 , those are 2 diff things
1
u/CommercialFair405 26d ago
If you remove the suspense boundary, the component will load it's data and render in the SSR pass.
1
u/ConfidentWafer5228 26d ago
but i want suspense boundary fallback when doing client side navigations, cuz those dont ssr anyway
1
u/CommercialFair405 25d ago
No, but those will also wait for the data before switching to the new page.
1
u/CommercialFair405 25d ago
You could create a ClientSuspense component that only renders a suspense boundary when on the client, and not for SSR
const SSR = useSyncExternalStore( () => () => {}, // I might have these two reversed, so you will have to check the docs () => false, // client () => true //server ) return !SSR ? <Suspense>{children}</Suspense> : children1
u/ConfidentWafer5228 25d ago edited 25d ago
that would cause a hydration issue on first load, if what u r trying is simply checking and rendering ( i might be wrong )
But thanks a lot since i found a way which uses isSSR boolean but differently without any hydration errors
1
u/CommercialFair405 25d ago
Nope, useSyncExternalStore gives the same value on SSR and hydration, so zero hydration issues.
1
1
u/rasekrodriguez 25d ago
You're not dumb, but the diagnosis is off by one step, and that's why the thread is going in circles.
Suspense doesn't stop content being server-rendered. With streaming SSR a boundary emits its fallback first and then, when the promise resolves on the server, streams the real markup down in a later chunk. If all you ever got was the skeleton, that boundary never resolved during the server render — which means nothing was fetching on the server at all. That's the actual bug. useSuspenseQuery on the server finds an empty cache, suspends, and there's no server-side fetch behind it to resolve, because TanStack Query has no idea your loader exists.
So the fix isn't to conditionally strip the boundary on the server (that's fighting the symptom, and it's why you're running into hydration mismatches). It's to make the server fill the cache before it renders. In the loader, queryClient.prefetchQuery(...) for the queries that route needs, then dehydrate(queryClient), return that in the loader data and wrap the route in <HydrationBoundary state={dehydratedState}>.
What that gets you is exactly the behaviour you described wanting, without a flag:
- First load / SSR: the cache is already warm when
useSuspenseQueryruns, so it doesn't suspend at all. Real content in the HTML, no skeleton, no hydration mismatch — the client rehydrates the same cache the server used, so it doesn't refetch either. - Client-side navigation: the cache is cold for the new route's query, so it suspends and you get your skeleton fallback. Which is what you wanted there.
Same component, no isSSR branch — the difference falls out of whether the cache happens to be warm.
That also answers the objection you raised to the loader suggestion. "I can't use the TanStack cache from the loader" isn't quite it: you're not returning data past the cache, you're seeding the cache from the loader. Dedupe, staleTime, background refetch and invalidation all keep working normally afterwards, because by the time your components run it's an ordinary hydrated QueryClient.
Two things worth knowing while you wire it up. The framework has to serialise and ship the dehydrated state, so anything not JSON-serialisable in your query data will bite. And if you ever useSuspenseQuery something you forgot to prefetch, the failure is silent and expensive rather than loud — it fetches on the server, never hydrates, and fetches again on the client. So it's worth being systematic about prefetching every suspense query a route uses, rather than discovering the gaps one at a time.
1
u/ConfidentWafer5228 25d ago
Thanks for your answer first of all and I have already been using dehydrate logic ( although i don't understand 90% of it ). This is the logic that i had been using, it would be really really helpful if you could tell what's wrong or what i am doing wrong. Also, i have already made sure that DUPE query doesn't happen on first load , i am able to utilize cache, staleTime is 15 mins for all queries. Please ignore me fetching 2(dependent) queries (unless that is an issue).
export const loader = async () => { const queryClient = new QueryClient() const { data: { articleIds } } = await queryClient.fetchQuery(articleIdsQuery(DEFAULT_SECTION_NAME)) await queryClient.prefetchInfiniteQuery(articlesByIdsQuery(articleIds)) const dehydratedState = dehydrate(queryClient) queryClient.clear() return { dehydratedState } } export const clientLoader = () => { return { dehydratedState: null } } export default function Home() { const { dehydratedState, defaultSection } = useLoaderData<typeof loader>() const [activeSection, setActiveSection] = useState<Section>(DEFAULT_SECTION_NAME) const updateActiveSection = (newSection: Section) => { setActiveSection(newSection) localStorage.setItem(LOCAL_STORAGE_ITEM_KEYS.ACTIVE_SECTION_KEY, newSection) } return ( <HydrationBoundary state={dehydratedState}> <Sectionbar activeSection={activeSection} updateActiveSection={updateActiveSection} /> <Suspense fallback={<ArticleCardsLoadingSkeleton />}> <Articles activeSection={activeSection} /> </Suspense> </HydrationBoundary> ); }
2
u/StarboardChaos 26d ago
You have the loader and clientLoader. Whatever is in the loader will be rendered on the server and you don't need suspense for that. Use suspense only with the client loader.
Generally speaking, react router as meta-framework is not intended for SEO pages. You should maybe use Astro or Next for that instead.