Skip to content

feat: add React Server Components support to @fastify/react - #441

Open
teneburu wants to merge 40 commits into
fastify:mainfrom
teneburu:feat/rsc-support
Open

feat: add React Server Components support to @fastify/react#441
teneburu wants to merge 40 commits into
fastify:mainfrom
teneburu:feat/rsc-support

Conversation

@teneburu

@teneburu teneburu commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Closes #365.

Summary

RSC support for @fastify/react built on @vitejs/plugin-rsc. The design respects the project's philosophy: minimal, modular, not invasive.

RSC is opt-in per route (export const rsc = true); the design stays at the route level. Non-RSC pages work as before — no migration, no defaults flipped. The router changes only at the content level via a RscContent bridge component. We deliberately avoid RSCHydratedRouter from react-router, which would take routing and data-fetching out of Fastify's hands. getMeta is mostly unchanged; @unhead/react injects into <head>.

Each RSC route gets a companion GET/POST route at {path}_.rsc in routing.js, used by mount.js and RscContent for payload fetches and server actions. RSC pages are detected via window.__FLIGHT_DATA injected by the SSR entry during rendering. The HTML shell needs only the existing <!-- element --> split marker and </body>; no shell changes are needed to upgrade. The e2e fixture at e2e/react-rsc/ covers 13+ route patterns (basic RSC, actions, streaming, auth, data-fetching) with a Playwright suite.

Design choices

Why @vitejs/plugin-rsc

The experimental react-19-rsc branch from #365 built on @hiogawa/transforms. @vitejs/plugin-rsc continues that work under the vitejs org, so this is a continuation, not a detour. It provides the 5-step build pipeline (scan rsc → scan ssr → build rsc → build client → build ssr), the react-server-dom-vite flight protocol integration (React PR #33152), and the setRequireModule / __vite_rsc_require__ runtime. Its serverHandler: false option lets us own the Fastify request handling while delegating build/transform concerns.

Three Vite environments (client / rsc / ssr)

The 3-environment split follows Waku and the @vitejs/plugin-rsc starter. Each environment has its own entry: rsc-entry (RSC request handler), ssr-entry (SSR with embedded flight data), and the existing client entry. The development.ts refactor reads viteConfig.environments directly instead of re-invoking the plugin's config hook, so it supports any number of environments rather than the hardcoded client/ssr pair.

Fastify stays in the routing and data-fetching loop

A rsc-handler.js adapter bridges Fastify's request/reply to the Web Fetch API Request/Response that matchRSCServerRequest and routeRSCServerRequest consume. This keeps Fastify's hook system, decorators, and middleware chain in the request path — we don't hand the request off to a separate Node server as the starter example does. See the Fastify routes reference for the routing contract this preserves.

Opt-in at the route level, not the framework level

export const rsc = true in a route module opts that route into RSC. server.js guards against rsc + getData mutual exclusion (RSC routes use server components for data, not getData). prepareClient picks up the RSC handler from entries.rsc. The preHandler skip for RSC routes avoids creating a StaticRouter that would conflict with RSCStaticRouter. The blast radius of RSC stays within the routes that ask for it.

mount.js / mount.ts hydration

Flight data is decoded before hydration (the canonical starter pattern). Two dev-mode quirks are documented inline: the __webpack_require__ polyfill + $cache= / $$cache= flight protocol mismatch (see react-server-dom-vite's removeReferenceCacheTag), and the React Refresh preamble flags, which must be set before createFromReadableStream because client modules loaded by the stream decoder check window.$RefreshReg$ synchronously at evaluation time.

TypeScript parity

All new virtual modules ship in both JS (virtual/) and TS (virtual-ts/) variants. The TS variants are faithful ports — same logic, same comments, with type annotations.

Reference links

What's included

  • 3-environment build (client/rsc/ssr) via @vitejs/plugin-rsc with serverHandler: false
  • RSC route opt-in via export const rsc = true — backwards compatible, defaults to false
  • rsc-handler.js Fastify-to-Request adapter
  • Three new virtual modules (rsc-entry, ssr-entry, rsc-content) in JS + TS variants
  • mount.js/mount.ts RSC hydration path with flight-data decoding, __webpack_require__ polyfill, Refresh preamble, formState hydration, setServerCallback registration
  • development.ts refactor to environment-based config
  • Duplicate React prevention (externalize react/react-dom/react-router from SSR bundle)
  • onEnter lifecycle support for RSC routes (invoked in rsc-entry.jsx's generateResponse, mirrors extractHeadMeta pattern)
  • Valtio state transfer across the RSC boundary via <ValtioHydrator> Flight prop pass (handles plain objects and Valtio proxies)
  • E2E fixture (e2e/react-rsc) with 13 Playwright tests covering all RSC features including Valtio state

Streaming

The RSC SSR path streams HTML progressively through ssr-entry.jsx's TransformStream (start → transform → flush, no controller.terminate()), and rsc-entry.jsx returns the Response directly (no cascading buffer).

A empty-response bug arised, caused by a one-line omission in routing.js: the async RSC route handler called sendResponse(reply, response) without return reply. Fastify's wrapThenable then raced the in-flight stream with a second reply.send(undefined), which set content-length: 0 and called res.end() — killing the stream mid-flight (fastify/fastify#4029, fastify/fastify#4018, fastify/fastify#6682). Adding return reply lets the stream pipe to completion.

Verified: all RSC routes return full chunked HTML; node --test 4/4 pass; Playwright 13/13 pass.

Verification

Check Result
Unit tests (@fastify/react) 13/13 pass
Unit tests (@fastify/vite) 78/78 pass + 8/8 typecheck
Production build (5 environments) Succeeds
node --test e2e suite 4/4 pass (1 skipped)
Playwright e2e — dev mode 13/13 pass
Playwright e2e — production mode 13/13 pass
pnpm lint 0 errors
pnpm format:check clean

Future work

  • Bridging Fastify context into RSC's lifecycle — RSC routes' onEnter currently passes through server and req, but reply remains inaccessible. The RSC handler uses the Web Fetch API (Request/Response) and runs independently of Fastify's reply lifecycle, so reply.redirect(), reply.setCookie(), and reply.header() called inside onEnter would be overwritten when the RSC response is applied afterward. Server actions ('use server') get no Fastify context at all — no instance, no request, no reply. This blocks server action access to Fastify decorators, plugins, and request context. A throw-based redirect mechanism (similar to Next.js's redirect()) and context injection via AsyncLocalStorage are the likely solutions.
  • Unified lifecycle — dropped. React Router's loader/meta APIs are Data Mode features (createBrowserRouter + RouterProvider) and cannot mix into the existing Declarative Mode (BrowserRouter + Routes). Switching would require a rewrite that breaks ctx.req/ctx.reply for every non-RSC route in a mixed app. The two paths coexist correctly as-is; onEnter and Valtio bridging are already implemented.
  • Adapt the React starter to ship demo RSC pages (separate from the e2e/react-rsc/ fixture), so users scaffolding a new project get a working RSC example out of the box.
  • Performance optimizations — the implementation prioritizes correctness; error handling around reply.send(response.body) stream consumption is a candidate for a follow-up pass.
  • TypeScript e2e coverage — the e2e fixture uses ts: false; the TS virtual-module variants are verified by code-inspection parity only. A TS e2e fixture would close that gap.
  • User-facing documentation — the RSC opt-in (export const rsc = true), the _.rsc companion route contract, and the @fastify/multipart requirement for server actions need documenting in the @fastify/vite docs site.

Changeset

  • @fastify/react: minor
  • @fastify/vite: patch

teneburu added 30 commits June 30, 2026 00:39
Export getRouteModuleExports so it can be used externally.
Add rsc: routeModule.rsc ?? false to the route module export map.
Move the file-path-to-route conversion logic into a shared utility
so it can be reused in server.js and the RSC entry virtual module.
Add rsc/getData mutual exclusivity validation.
Register @vitejs/plugin-rsc and set up the RSC Vite environment for both
dev and build modes. Prevent the default build pipeline from overriding
@vitejs/plugin-rsc's own 5-step build sequence.
Register three new virtual modules accessed via /:
- rsc-entry.jsx — RSC request handler entry (processes RSC payload and server actions)
- ssr-entry.jsx — SSR handler entry (generates full HTML with RSC payload embedded)
- rsc-content.jsx — Client RscContent bridge component for RSC route rendering
Add TS variants of rsc-entry, ssr-entry, and rsc-content virtual modules
for projects using the ts: true plugin option.
Add convertRequest and sendResponse utilities for bridging Fastify's
request/reply interface to the Web Fetch API Request/Response used by
@vitejs/plugin-rsc.
Wire RSC route handling in createRoute and prepareClient, register
companion _.rsc routes for client-side fetch/action URLs, and delegate
RSC route rendering to RscContent in the core component.
Add RSC detection and hydration to the client entry point. Handles
RSC payload decoding via rsc-html-stream, __webpack_require__ polyfill
for pre-bundled vendor files, React Refresh preamble ordering, and
formState-based hydration.
Add resolveId, load, and transform hooks for handling:
- Runtime alias resolution for @vitejs/plugin-rsc and #runtime paths
- JSX transformation in virtual modules (null-byte prefixed)
- SSR scan build skipping for 'use client' components
- @vitejs/plugin-rsc bare specifier resolution for virtual modules

Enhance the config hook to prevent duplicate React copies across
environments and resolve react-router's react-server entry.
Replace the ViteEnvironmentsConfig/findPlugin/hasPlugin pattern with
direct iteration over viteConfig.environments. This supports the RSC
environment without requiring manual plugin config hook invocation.
Add a complete RSC e2e test fixture (e2e/react-rsc) covering:
- Non-RSC and RSC pages with async server components
- 'use client' interactive components (Counter)
- Server actions with progressive enhancement
- Error boundary testing
- getMeta head metadata on RSC routes
- Playwright-based e2e test suite

Update virtual modules to final versions with:
- Head metadata injection via getMeta
- startTransition-wrapped state updates
- Error boundary component in RscContent
- Null-byte prefix handling in virtual module resolution
The TypeScript core virtual module should import /rsc-content.tsx
(the TS variant) instead of the JS variant.
Add new test pages and infrastructure to the RSC e2e fixture:
- Streaming page with deferred server data
- Server-side data fetching patterns
- Shared store usage demonstrations
- Auth-protected route with auth layout
- Server action with dedicated action module
- Separated server action logic into individual files

Update Playwright test suite with expanded coverage.
Add playwright config and resolution test utility.
Extend Vite plugin with dev mode environment helpers.
…ontext conflicts

- Strip _.rsc suffix from request URL before matchRSCServerRequest
  (companion routes were returning 404 — React Router doesn't know
  about _.rsc suffixed paths)
- Fix mount.js = polyfill matching inside 6256cache= (dangling $
  in module URLs like /components/foo.jsx$)
- Use react-dom/server instead of .edge in ssr-entry to prevent
  LocationContext._currentValue contamination between node and edge
  renderer modules (caused 'Router inside another Router' after
  non-RSC page renders)
- Restore routeRSCServerRequest as designed react-router API
- Skip client.create() preHandler for RSC routes (was rendering a
  StaticRouter that conflicted with RSCStaticRouter)
- Fix production outDir path resolution (join relative outDir against
  absolute config.root, not config.vite.root)
- Add dev/prod environment-aware loadHtmlTemplate candidate ordering
- Add useActionState CounterForm component for /actions page
- Add graceful fallback when youch is unavailable in production
- Update e2e tests with real button-click interactions for Counter
  (+/-), increment server action (0->1->2), and data fetch button
- Fix production error page test (React sanitizes error messages)

All 13 e2e tests pass in both dev and production modes.
Port the full RSC hydration logic from virtual/mount.js to virtual-ts/mount.ts:
__FLIGHT_DATA detection, createFromReadableStream, __webpack_require__
polyfill, __vite_rsc_require__ wrapping, React Refresh preamble,
RscRoot component, setServerCallback registration, and formState hydration.

Previously the TS variant (ts: true) had no RSC hydration at all.
… rsc-content

Replace payload.root with payload.matches?.[0]?.element to match the
actual React Router RSC payload shape (matches[]), and remove the
createFromReadableStream(rscStream) initial-hydration path that
duplicated mount.ts's responsibility and caused a visible flicker.
…entry

Remove the top-level 'import { Youch } from youch' that defeated the
dynamic-import fallback in the catch block (the module would fail to
load entirely if youch was absent from the RSC environment's graph).
Replace the inline filePathToRoutePath duplicate with an import from
#runtime/route-utils.js, matching the JSX variant.
The SSR entries strip the <script id="_R_"> element from the HTML
before it reaches the browser, so document.getElementById('_R_') was
dead code. RSC page detection now relies solely on window.__FLIGHT_DATA.
Add the 8 new RSC files (route-utils.js, rsc-handler.js, 3 virtual/
and 3 virtual-ts/ entries) to the package.json files field so they
ship to npm. Without this, loadVirtualModule('rsc-entry.jsx') fails
with MODULE_NOT_FOUND for anyone installing from the registry.
Replace t.test.skip(...) (not a valid node:test API on TestContext)
with t.test('...', { skip: true }, fn) — the old form threw
TypeError: t.test.skip is not a function and aborted the entire
e2e suite after the build subtest.

Delete test-resolution.mjs (a diagnostic script that imported
@vitejs/plugin-rsc directly, which isn't a dependency of the e2e
package and caused ERR_MODULE_NOT_FOUND under pnpm's strict
node_modules when glob-matched by node --test).
Shared test helper that asserts an RSC page response returns 200,
embeds __FLIGHT_DATA in the HTML, and is a valid HTML document.
Reusable across RSC e2e fixtures.
Explicitly disable process.env inheritance in the RSC environment
config so server-only code doesn't accidentally read host environment
variables via process.env during RSC rendering.
The development.ts refactor (iterating viteConfig.environments directly
instead of re-invoking the plugin config hook) changed the contract:
loadEntryModulePaths now reads viteConfig.environments.ssr.build.rollupOptions.input
to discover entry paths. Add the environments field to the test mock
so createServerModuleRunner is exercised as intended.
…RSC routes

- Wire onEnter in rsc-entry.jsx generateResponse (mirrors extractHeadMeta
  pattern). ctx.req/ctx.reply explicitly null (Web Fetch API context).
- Add ValtioHydrator 'use client' component that snapshots Valtio state
  server-side and reconstructs the proxy client-side via RouteContext.Provider.
- Thread req.route.state through rsc-handler.js via request.__valtioState.
- Handle both plain objects (from context.js state()) and Valtio proxies
  as input — snapshot() only works on proxy objects.
- Register valtio-hydrator virtual module in JS and TS variants.
…eDisplay

- Add context.js seeding initial Valtio state ({ count: 42, message })
- Add StateDisplay 'use client' component reading state via useRouteContext()
- Update using-store.jsx to import StateDisplay, replacing static placeholder
- Update test 12 to verify actual state values render (Count: 42, Message)
Compact multi-line t.test.skip call to single-line in server.test.js.
Wrap long line in test-factories.mjs makeRscIndexTest.
…a into Valtio state

- Pass valtioState into extractOnEnter so ctx.state is populated from the
  real state object instead of null (matches non-RSC semantics).
- Merge onEnterData into valtioState before snapshot+ValtioHydrator wrapping
  so client components can read it via useRouteContext() + useSnapshot().
- Remove onEnterData from rscPayload spread (now redundant after merging).
…stead of try/catch around snapshot

Valtio's snapshot() calls console.warn('Please use proxy object') before
throwing TypeError when called on a plain object. The try/catch caught
the error but the console.warn had already fired. Use getVersion() to
check if the object is a Valtio proxy silently before calling snapshot().
teneburu added 3 commits July 1, 2026 00:32
…double SSR ModuleRunner

loadEntries() called createServerModuleRunner(envConfig) unconditionally,
creating a separate ModuleRunner. When @vitejs/plugin-rsc later performed
cross-environment imports via import.meta.viteRsc.import('ssr', ...),
RunnableDevEnvironment.runner lazy getter created a second independent
ModuleRunner — causing duplicate '(ssr) connected' logs and duplicate
HMR connections.

For RunnableDevEnvironment instances, use envConfig.runner (the same
cached getter the RSC plugin uses) instead of createServerModuleRunner.
…ent-Length from streaming responses

Return reply from the async RSC route handler in routing.js so Fastify's
wrapThenable doesn't race the streaming response with a second reply.send(undefined)
that sets content-length: 0 and kills the stream mid-flight.
See fastify/fastify#4029, #4018, #6682.

Strip Content-Length from rsc-handler.js streaming responses — react-router's
routeRSCServerRequest sets this header on RSC payloads, which short-circuits
Fastify's chunked transfer encoding.
Replace the buffered HTML assembly in rsc-entry.jsx with direct streaming
- removes the ReadableStream buffering loop and TextDecoder join
- adds a defensive guard for empty-body responses
- passes the ssr-entry Response through unchanged

Refactor ssr-entry.jsx TransformStream to progressive streaming pattern:
- start() enqueues the head-injected template before any content
- transform() strips the _R_ bootstrap script per-chunk
- flush() appends RSC payload scripts and template tail
- no controller.terminate() — return from flush() closes both sides
@changeset-bot

changeset-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 69af69d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@fastify/react Minor
@fastify/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

teneburu added 5 commits July 1, 2026 02:07
- ssr-entry.tsx: use react-dom/server instead of .edge to prevent
  LocationContext._currentValue contamination ('Router inside another
  Router' after non-RSC page renders)
- ssr-entry.tsx: stream HTML progressively via start/transform/flush
  TransformStream instead of buffering all chunks into one string
- ssr-entry.tsx: wrap readRSCPayload in try/catch with console.error
  fallback to avoid unhandled rejections crashing the render
- ssr-entry.tsx: add dev/prod-aware loadHtmlTemplate candidate ordering
  so dev mode doesn't load hashed production build artifacts
- rsc-entry.tsx: pass htmlResult Response through directly instead of
  constructing new Response(htmlResult.stream) — .stream is undefined
  on Response objects, producing empty response bodies
- rsc-entry.tsx: wrap youch dynamic import in nested try/catch with
  minimal HTML error fallback when youch is unresolvable in the RSC
  bundle's module graph
- rsc-entry.tsx: add console.warn to silent getMeta error catch in
  resolveGetMeta, matching extractOnEnter error reporting pattern
- rsc-entry.jsx: add console.warn to silent getMeta error catch in
  extractHeadMeta for consistency
The applyHeadFromPayload function only set document.title with a TODO
comment ('Additional meta/link updates can be added here'). Inline the
title assignment directly into the existing useEffect and drop the
unused abstraction.
getRouteModuleExports throws when a route module exports both rsc: true
and getData() — these are mutually exclusive since RSC routes use server
components for data fetching. Add a test verifying the throw and its
message matches /mutually exclusive/.
- rsc-entry.tsx: drop unused catch binding 'e' (optional catch binding)
- rsc-entry.tsx: remove unused 'let htmlResponse' declaration
- rsc-entry.jsx: drop unused 'loader' destructuring in fallback manifest loop
- mount.js/mount.ts: remove setRequireModule from destructuring — the
  module's initialize() runs on import, the binding itself is never
  called directly
- e2e/react-rsc/client/context.js: drop unused ctx param from init()
@teneburu
teneburu force-pushed the feat/rsc-support branch from 705894b to 0b4247f Compare July 1, 2026 00:14
@teneburu

teneburu commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

I like this project a lot, I use it often. I wanted to experiment with it by trying to get this feature into my favorite stack, and get to learn about the internals. I hope to spark some conversation about the direction this project is taking. Should it follow in the footsteps of upstream packages (react-router's RSC) ?
How does one implement RSC without turning the framework into a beast that demands control at every layer?
This is my best attempt at answering this question.

I know this is a big first PR — I hope it's not too much too soon.
@galvez said in his article:

Maybe a @fastify/react-plus of sorts could become a thing, but as a separate renderer package. My vision is that both @fastify/vue and @fastify/react remain as simple and minimal as they can possibly be.

I feel like this is a better option. RSC is a React feature, it needs to interact with the React code, and the integration points live in the rendering pipeline itself (mount, core, routing, server). A separate package would mean duplicating those files or poking hooks into the minimal shell anyway — so if the problem is to make this the least invasive as possible, I think it's achievable within the core package boundaries, and it's simpler to do it there.

Happy to restructure, split into smaller PRs, or address any feedback.

@onlywei

onlywei commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@teneburu wow wow wow, this is amazing.

I am the current maintainer of this repo after taking it over from @galvez, but to be honest I don't use @fastify/react, I just use @fastify/vite on its own. Thus, I don't have a great sense of the strengths and weaknesses of @fastify/react.

So I want to gather input from those who do use @fastify/react about this one. Do you use it a lot? @mcollina, @climba03003 do you use @fastify/react?

@teneburu

teneburu commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Nice to meet you, @onlywei !

Do you use it a lot?

I run a web development agency. I use @fastify/react when the client gives me the liberty of choice and when it makes sense. Otherwise I use it in my own infrastructure and when spinning up side projects because I like using Fastify.

What makes it good: SSR & bridging. Access to ctx in getData(), onEnter(), and getMeta() ties client and SSR to the Fastify instance without effort. Minimal, raw server access. SSR works perfectly with full control over the HTML.

The React team made their bet — server-first. RSC is the default in React 19. This project should embrace that direction, not fight it.

I currently use oRPC for client-side data fetching, a full RSC implementation would replace that.

React Router is actively maintained by people who are paid to do it, it works, and it stays out of your way. But it wants to own too many things — things Fastify should own. Routing, data loading, middleware, actions. That's what we want Fastify to do; not a client-side router.

If I were to choose, I'd work to remove React Router from the stack little by little because it stands in the way of ideal Fastify access. Or find a way to make the contradictions of Fastify and React Router less impactful.

This PR is 3,000 lines, and is still incomplete. It sets a structural direction for RSC in @fastify/react.
The biggest thing left to solve is bridging Fastify context into RSC's lifecycle.


What you'd gain with full Fastify bridging in RSC

Current integration points

What Where Has Fastify context?
onRequest hook routing.js:53 server, req, reply
preHandler hook routing.js:57 (skipped for RSC) ❌ skipped for RSC
Route-level hooks (onRequest, preParsing, etc.) routing.js:149 ✅ per-hook
convertRequest() rsc-handler.js:7 ✅ reads req.headers, req.body etc.
onEnter lifecycle rsc-entry.jsx:106 server, req / ❌ reply
Server components RSC environment ❌ no access
Server actions ('use server') RSC environment ❌ no access

1. Authentication & request context in onEnter

onEnter runs before the RSC render. Use it for Fastify-level side effects — auth, cookies, redirects. This is the correct place for things that need the Fastify request/reply lifecycle.

// pages/dashboard.jsx — RSC route
export const rsc = true

export async function onEnter(ctx) {
  const user = await ctx.server.authenticate(ctx.req, ctx.reply)
  if (!user) {
    // Needs throw-based redirect 
    throw redirect('/login')
  }
  const locale = ctx.req.cookies.locale ?? 'en'
  const isMobile = /mobile/i.test(ctx.req.headers['user-agent'])
  return { user, locale, isMobile }
}

// Server component fetches data directly — no onEnter needed
export default async function Dashboard() {
  const posts = await db.query.posts.findAll({ limit: 10 })
  return (
    <div>
      <h1>Welcome {user?.name}</h1>
      <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  )
}

Gain: Auth, cookies, and redirects work the same way as non-RSC routes. Data fetching stays server-side where it belongs.


2. Server actions with Fastify context (future work)

Server actions currently get no Fastify context — no server, no request, no reply. With AsyncLocalStorage, they could use the full Fastify plugin stack.

'use server'

// Currently: no context. Must import db directly.
export async function createPost(data) {
  const user = await getCurrentUser()        // no access to req
  await db.query.posts.create(data)          // direct import, not server.db
  await email.send(user.email, 'Post created') // direct import
}

// With ALS: Fastify decorators and plugins available
export async function createPost(data) {
  const { server, req } = getFastifyContext()
  const user = await server.auth.getUser(req)
  await server.db.query.posts.create({ ...data, userId: user.id })
  await server.email.send(user.email, 'Post created')
}

Gain: Server actions use the same Fastify decorators and plugins as non-RSC routes. Consistent API surface.


3. Server components accessing Fastify decorators directly (future work)

With AsyncLocalStorage, server components could read Fastify context without routing through onEnter:

export default async function Page() {
  const { server } = getFastifyContext()
  const posts = await server.db.query.posts.findAll()
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}

Gain: One consistent DB plugin for all routes. Server components import getFastifyContext() instead of importing db directly.


4. Fastify plugin ecosystem on RSC routes

Route-level hooks (onRequest, preParsing, etc.) already work on RSC routes:

export const onRequest = [rateLimit({ max: 10, timeWindow: '1 minute' })]
export const preValidation = [schemaValidator(mySchema)]

Plus whatever is registered:

  • @fastify/cookiereq.cookies
  • @fastify/rate-limit → rate limiting on RSC routes
  • @fastify/multipart → file uploads in server actions
  • @fastify/cors → CORS for RSC companion routes

Summary table

Capability Current RSC With full Fastify bridge
Read cookies req.cookies via onEnter Same
Authenticate user server.auth() via onEnter Same
Redirect in onEnter ❌ sendResponse overwrites it throw redirect() (Next.js pattern)
Set response cookies/headers Deferred reply or ALS
Server components access Fastify decorators ❌ direct import getFastifyContext() via ALS
Server actions use Fastify plugins ❌ direct import getFastifyContext() via ALS
Route-level Fastify hooks ✅ works Same
File uploads in server actions ✅ partial Better with full context

I'm glad this sparks conversation. What do you think ?

I'd love to help push this forward in my free time. I benefit from keeping this project alive. If you're open to it, tell me where you want help and I'll make time.

teneburu and others added 2 commits July 2, 2026 00:48
Attach request.__server and request.__req in convertRequest() so
the RSC handler's extractOnEnter can populate ctx.server and ctx.req
instead of leaving them null. This gives RSC routes read-only access
to the Fastify instance, decorators, plugins, and request properties
(cookies, ip, headers) inside onEnter.
@teneburu

teneburu commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@onlywei

onlywei commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

@teneburu Thanks for the summary. Do you think you can split the changes to @fastify/vite out into its own separate PR so it's easier for me to review? Thank you!

@teneburu

teneburu commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Done ! @onlywei
fastify/fastify-vite/pull/442

@teneburu

Copy link
Copy Markdown
Contributor Author

I developed this idea further in my own fork at cyb3rcore/reactify — It's there if you want to see how it evolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

React Server Components (RSC) support for @fastify/react

2 participants