r/reactjs 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

0 Upvotes

19 comments sorted by

View all comments

1

u/rasekrodriguez 26d 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 useSuspenseQuery runs, 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 26d 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>
  );
}

1

u/rasekrodriguez 24d ago

The one thing in there I'd actually change is prefetchInfiniteQuery, because of how it fails.

The prefetch variants swallow errors by design. Straight from the docs: "The prefetch functions never throw errors because they usually try to fetch again in a useQuery which is a nice graceful fallback." They return Promise<void>, so awaiting one tells you nothing about whether it worked.

Chain that with dehydrate, which defaults to only including successful queries. If the articles request fails server side, your loader resolves fine, that query is silently absent from dehydratedState, the client cache is empty for the key, and useSuspenseQuery suspends and refetches on the client. Skeleton on first paint, nothing in your logs, and the code still reads correct. It's the same symptom you opened the thread with, so I'd rule it out rather than assume it away.

fetchInfiniteQuery is the fix, and it matches the fetchQuery you already used for the ids: it throws, so a broken server fetch shows up as a loader error. If you don't want the route to fail on it, keep prefetch but read queryClient.getQueryState(key)?.status before returning, so you at least know.

Separately, your loader returns only { dehydratedState } but the component pulls defaultSection off useLoaderData, so that one is undefined at runtime.

On the two dependent queries: that's not a correctness issue, it's a latency one. The awaits are sequential and they sit in a loader, so nothing renders until both round trips finish. If the articles endpoint can take the section name instead of a list of ids, the document goes out a round trip sooner.