While migrating Shelter Sense to RedwoodSDK (the full story is in Leaving Next.js for RedwoodSDK), I hit a bug that looked exactly like a routing problem. useSearchParams threw during hydration. A Suspense fallback sat there forever. Everything pointed at the router.
The actual cause: I had put a navigation context provider in Document, the component where your <html>, <head>, and <body> live. Coming from Next.js, that felt like the obvious spot. It is the root layout, right? Providers go at the root.
Except in rwsdk, Document is not the root of anything your pages live in. Your page is never a child of Document. Not conceptually, not at runtime, not ever. I dug into the rwsdk source to understand why, and the answer is neat enough that I think it deserves its own short write-up.
01Two renders, not one
Here is the setup that looks correct and silently is not:
// ❌ This provider will never be an ancestor of your pages
export const Document = ({ children }: DocumentProps) => (
<html lang="en">
<head>...</head>
<body>
<MyNavigationProvider>{children}</MyNavigationProvider>
<script>import("/src/client.tsx")</script>
</body>
</html>
);
React context flows down through a component tree during a render. So the question is: is there any render in which Document and your page are one tree? In rwsdk, there is not. This is renderDocumentHtmlStream from the rwsdk source (trimmed for clarity):
// rwsdk/dist/runtime/render/renderDocumentHtmlStream.js (abridged)
const outerHtmlStream = await renderHtmlStream({
node: documentElement, // your <Document>, rendered on its own
identifierPrefix: '__RWSDK_DOCUMENT__',
});
const appHtmlStream = await renderHtmlStream({
node: innerAppNode, // your routed page tree, rendered on its own
});
return stitchDocumentAndAppStreams(
outerHtmlStream,
appHtmlStream,
'<div id="rwsdk-app-start"></div>',
'<div id="rwsdk-app-end"></div>',
);
Two separate calls to renderHtmlStream. Your Document is rendered to completion with a placeholder <div id="rwsdk-app-start"> where the app will eventually go. Your page tree comes out of the RSC payload and gets its own, entirely independent render. Even the little identifierPrefix: '__RWSDK_DOCUMENT__' is a tell: it exists so the document render's useId values cannot collide with the app render's, because they are two different React renders that know nothing about each other.
So the "children" your Document receives are not your page. They are a marker div. The real composition happens later, and not in React at all.
02The stitcher: composition as text
The two renders produce two HTML byte streams, and a function called stitchDocumentAndAppStreams splices them together. It is a small state machine that walks through phases, switching between the document stream and the app stream as it finds markers in the raw HTML:
1. Stream the document until <div id="rwsdk-app-start"> appears
2. Switch to the app stream; stream the non-suspended shell
3. Switch back to the document to send the client <script> tags
4. Switch back to the app for the suspended (Suspense) content
5. Finish the document: closing </body> and </html>
Reading it feels less like React and more like a tiny compiler pass: indexOf('</head>'), indexOf(startMarker), buffer, splice, flush. Your app's HTML replaces the marker div textually, in the byte stream, after both React renders are already done producing output.
And the design earns its keep. Because the streams are interleaved rather than nested, rwsdk can hoist your app's <title> and <meta> tags up into the document <head>, and, more importantly, it can send the hydration <script> tags before your suspended Suspense content finishes resolving. The browser starts loading client JavaScript while slow server components are still streaming in. That ordering is impossible if the document has to wait for its children like a normal React parent.
But it also makes the context question unambiguous. Context propagation is a React-render-time mechanism, and this composition happens at the text level after render. There is no channel for a value in the document tree to reach the app tree. Not a leaky one, not a partial one. None.
03The client side seals it
Suppose the server somehow did not matter. The browser settles it anyway. rwsdk's client entry hydrates only the app:
// client.tsx
import { initClient } from 'rwsdk/client';
initClient(); // hydrates the RSC payload into #hydrate-root
Hydration mounts your page tree into #hydrate-root. Document is never hydrated at all. It is inert HTML that happens to surround your app, the same way an index.html surrounds a classic SPA. A provider written in Document does not exist in the browser's React tree, so there is nothing for useContext to find even in principle.
That is why my bug looked so much like a routing problem. The provider was right there in the JSX, plain as day, wrapping what looked like the app. But on the server it wrapped a placeholder div, and on the client it did not exist. useSearchParams reached for context, found nothing, and threw inside a Suspense boundary, which quietly pinned the fallback forever.
The fix is boring once you know it: providers go inside the routed app tree, as a layout.
// ✅ worker.tsx: the layout is part of the app render, so context works
export default defineApp([
render(Document, [
layout(NavigationProviderLayout, [route('/', HomePage), prefix('/blog', blogRoutes)]),
]),
]);
Document keeps the things that genuinely belong to the page shell: <html>, <head>, stylesheets, the client entry script. Anything a component might want to consume lives in a layout.
Key Takeaways
rwsdk renders
Documentand your app as two independent React renders, then stitches the resulting HTML streams together as text. Your page is never a React child ofDocument.That design is what lets rwsdk hoist meta tags and ship hydration scripts before suspended content resolves, so it is a feature, not an accident.
The practical rule: context providers go in a layout inside the routed app tree.
Documentis for document chrome only.The failure mode is sneaky: a misplaced provider shows up as
useSearchParamsthrowing and a Suspense fallback hanging forever, which looks exactly like a routing bug.
This came out of the larger migration story, which covers why we left Next.js and what RedwoodSDK fixed for us: Leaving Next.js for RedwoodSDK.




