Proposal: a directive that pins a module to another environment ("use ssr"), built on import.meta.viteRsc.import
Follow-up to #1263, which was closed pointing at import.meta.viteRsc.import(..., { environment: 'ssr' }). That helper is the right mechanism, and this proposes the ergonomic layer on top of it, offered as a PR if the shape is welcome.
The pain, restated
Any library that renders React to HTML (@react-email/render, renderToString, PDF and Markdown renderers that use React) fails when called from a server function or an RSC route handler:
Error: react-dom/server is not supported in React Server Components.
As designed by React, and the cure is to run that code in the ssr environment. Today that means every framework on plugin-rsc (TanStack Start, vinext, ours) documents the same paragraph telling users to split a file and write:
const { renderEmail } = await import.meta.viteRsc.import<typeof import('./email-renderer')>(
'./email-renderer',
{ environment: 'ssr' },
)
It works, but it is the framework's plumbing leaking into an app: an env-specific global, a generic parameter that repeats the specifier, and an await at the call site because the import is now a promise.
Proposal
A module can declare which environment it belongs to, the way "use client" does:
// src/lib/email/render.tsx
"use ssr";
import { render } from '@react-email/render'
import { OtpEmail } from './otp-email'
export async function renderOtpEmail(code: string) {
return render(<OtpEmail code={code} />)
}
Everything else imports it normally. In the rsc environment the plugin replaces the module with proxies of its exports that call across:
const mod = import.meta.viteRsc.import('./render.tsx', { environment: 'ssr' })
export const renderOtpEmail = async (...args) => (await mod).renderOtpEmail(...args)
Dev and build both already work through the existing rsc:import-environment handling; the transform only needs to run ahead of it (enforce: 'pre', applyToEnvironment: rsc), so the module's own imports (react-dom/server among them) are never resolved in rsc at all.
Rules, enforced at transform time with the export named:
- exports are async functions (the call crosses environments; the answer is a promise)
- a value export, a sync function, a class,
export { } / export * is an error
- pass data across, not elements: an element created in
rsc carries that side's react
Shape questions for the maintainers
- Name and generality.
"use ssr" reads well but hardcodes the conventional environment name. A generic form, e.g. "use environment: ssr" or a plugin option directives: { 'use ssr': 'ssr' }, keeps it framework-agnostic. Happy with either.
- React's directive space.
"use *" directives are React's namespace; this one is bundler-only and never reaches React. Worth a note in the README rather than a blocker, I think.
- A friendlier failure for the direct import. Independently of the directive: resolving
react-dom/server* in rsc to a module that throws a message naming the app file that imported it and the fix. Cheap, and it turns the most-asked question into a message.
Prior art
Implemented and shipping in rsc-kit (packages/core/src/useSsr.ts, ~100 lines plus tests, including a fixture that renders a component with useState through renderToStaticMarkup from a server action): rsc-kit/rsc-kit#96. I will adapt it to whatever shape you prefer and open the PR.
Proposal: a directive that pins a module to another environment (
"use ssr"), built onimport.meta.viteRsc.importFollow-up to #1263, which was closed pointing at
import.meta.viteRsc.import(..., { environment: 'ssr' }). That helper is the right mechanism, and this proposes the ergonomic layer on top of it, offered as a PR if the shape is welcome.The pain, restated
Any library that renders React to HTML (
@react-email/render,renderToString, PDF and Markdown renderers that use React) fails when called from a server function or an RSC route handler:As designed by React, and the cure is to run that code in the
ssrenvironment. Today that means every framework on plugin-rsc (TanStack Start, vinext, ours) documents the same paragraph telling users to split a file and write:It works, but it is the framework's plumbing leaking into an app: an env-specific global, a generic parameter that repeats the specifier, and an
awaitat the call site because the import is now a promise.Proposal
A module can declare which environment it belongs to, the way
"use client"does:Everything else imports it normally. In the
rscenvironment the plugin replaces the module with proxies of its exports that call across:Dev and build both already work through the existing
rsc:import-environmenthandling; the transform only needs to run ahead of it (enforce: 'pre',applyToEnvironment: rsc), so the module's own imports (react-dom/serveramong them) are never resolved inrscat all.Rules, enforced at transform time with the export named:
export { }/export *is an errorrsccarries that side'sreactShape questions for the maintainers
"use ssr"reads well but hardcodes the conventional environment name. A generic form, e.g."use environment: ssr"or a plugin optiondirectives: { 'use ssr': 'ssr' }, keeps it framework-agnostic. Happy with either."use *"directives are React's namespace; this one is bundler-only and never reaches React. Worth a note in the README rather than a blocker, I think.react-dom/server*inrscto a module that throws a message naming the app file that imported it and the fix. Cheap, and it turns the most-asked question into a message.Prior art
Implemented and shipping in rsc-kit (
packages/core/src/useSsr.ts, ~100 lines plus tests, including a fixture that renders a component withuseStatethroughrenderToStaticMarkupfrom a server action): rsc-kit/rsc-kit#96. I will adapt it to whatever shape you prefer and open the PR.