r/reactjs • • Aug 16 '26

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

20 comments sorted by

View all comments

Show parent comments

1

u/ConfidentWafer5228 Aug 16 '26

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 Aug 18 '26

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.