Fix: react-dom/server Import Error in Next.js App Router
"You're importing a component that imports react-dom/server" is a compile-time rule of the App Router, not a bug in your code. Here is what the rule protects, which use cases are legitimate, and the fixes verified against Next.js 15.5 and 16.3 with both webpack and Turbopack.
Photo by Dina Badamshina on Unsplash
Import react-dom/server inside an App Router page and the build stops before
anything renders:
Failed to compile.
./app/page.js
You're importing a component that imports react-dom/server. To fix it, render
or return the content directly as a Server Component instead for perf and
security.
Maybe one of these should be marked as a client entry "use client":
app/page.jsThat is the message reported in
the GitHub issue vercel/next.js#43810,
opened against Next.js 13.0.7-canary. The reporter's page called
ReactDOMServer.renderToStaticMarkup(<p>TEST</p>) at module level, then
returned normal JSX. The thread stayed open for years and collected the
legitimate use cases: an RSS feed rendered from JSX, MDX converted to plain
text for a Meilisearch index, a React SVG turned into a PNG in a route.tsx
handler. They were all hitting a boundary rule that the message explains badly.
The wording is unchanged today. The same static import in a page or a
route.ts file produces the identical error on Next.js 15.5.25 and 16.3.4,
with both webpack and Turbopack. Below: what the rule protects, the three
fixes that work on current versions, and the thread workarounds to avoid.
Root cause: two React renderers in one module graph#
The App Router compiles page.tsx, layout.tsx, route.ts and everything
they import in the React Server module layer. In that layer, React is
already the renderer: your Server Component tree is turned into the RSC
payload, and Next.js later turns that payload plus your Client Components into
HTML. The
server and client boundary guide
describes the two module graphs and why code in the server graph never reaches
the browser.
react-dom/server is the other renderer. renderToString and
renderToStaticMarkup start an independent, synchronous, client-style render
of whatever element you pass them. Inside a Server Component that means:
- A second React tree with no access to the RSC environment: no
asynccomponents, nocookies()orheaders(), nouse cache, no streaming Suspense boundaries. Anything that relies on those throws or renders the fallback. - Work that runs on every request in the middle of a render that Next.js is trying to prerender and cache, hence the "perf" part of the message.
- Component code that was supposed to stay on the server being pulled into a string you might send anywhere, hence the "security" part.
So Next.js added a compile-time check: a static import ... from 'react-dom/server' in the server layer fails the build. The "use client"
hint is a generic suggestion appended to several boundary errors, and it is the
wrong fix here (see the anti-fixes section).
There is a second guard at the React level. React 19's react-dom package
publishes a react-server export condition for ./server that resolves to a
stub whose entire body is
throw new Error('react-dom/server is not supported in React Server Components.').
That is the runtime error some commenters in the thread hit in 2024 after the
compile-time check was bypassed. Keep both messages in mind: the first is
Next.js refusing the import; the second is React refusing the call.
Fix 1: return JSX (the case you probably have)#
If the string you are building ends up in the same response, you do not need
react-dom/server at all. The original reporter's example renders markup and
then returns different markup; a Server Component can just return the element:
// app/page.tsx
import { Preview } from '@/components/preview'
export default function Page() {
// No renderToStaticMarkup. React renders <Preview /> as part of this tree.
return <Preview />
}The same applies to "render a component, then wrap it in an iframe" patterns: serve the inner document from its own route and point the iframe at that URL. The App Router guide covers the composition patterns (children slots, passing rendered elements as props) that replace most string-rendering habits carried over from the Pages Router.
Fix 2: dynamic import inside a Route Handler#
Some use cases genuinely need an HTML string that is not the page response:
an RSS or Atom feed built from JSX, an HTML email template, a sitemap with
JSX helpers, or a plain-text index for search. Put that rendering in a Route
Handler and load react-dom/server with a dynamic import:
// app/feed/route.tsx
import { Feed } from '@/components/feed'
import { getPosts } from '@/lib/posts'
export const dynamic = 'force-dynamic'
export async function GET() {
const { renderToStaticMarkup } = await import('react-dom/server')
const posts = await getPosts()
const html = renderToStaticMarkup(<Feed posts={posts} />)
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}Verified on a fresh project with Next.js 15.5.25 and 16.3.4 (React 19.2.8):
next build compiles, and curl /feed returns the rendered markup with both
the default bundler and --webpack. The identical handler with a static
import { renderToStaticMarkup } from 'react-dom/server' at the top fails the
build on every combination.
Why the dynamic form passes: the compile-time check inspects static import
declarations in the server layer. A dynamic import() is resolved at runtime
by Node, which picks the node build of react-dom/server rather than the
react-server stub. In the same test, the dynamic import also worked inside a
Server Component page. Prefer the Route Handler anyway: it keeps a
request-time, non-cacheable render out of your page tree, which is exactly
what the error message asks for.
Rules that keep this from biting later:
- Pass only client-compatible components to
renderToStaticMarkup. No async components, nocookies()/headers()calls inside, no Server Actions. Fetch data in the handler first and pass it as props, as above. - Use
renderToStaticMarkupfor feeds, emails and indexes.renderToStringadds hydration markers you do not want in a feed; the React reference spells out the difference. - If TypeScript reports
TS7016: Could not find a declaration file for module 'react-dom/server', install@types/react-dom. The build in the test above failed on exactly that until the types were present. - Set
export const dynamic = 'force-dynamic'(or a revalidation window) so the handler is not prerendered at build time with stale data. The same bail-out rules that apply to pages apply here; see Dynamic Server Usage errors if the build complains about request-time APIs.
Fix 3: images from JSX use ImageResponse, not renderToString#
One commenter in the thread rendered a React SVG to a string, then pushed it
through sharp to produce a PNG. Next.js has a first-class API for that
path, and it is allowed in the server layer:
// app/api/badge/route.tsx
import { ImageResponse } from 'next/og'
export async function GET() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0f172a',
color: 'white',
fontSize: 48,
}}
>
Deployed to eu-central-1
</div>
),
{ width: 1200, height: 630 }
)
}ImageResponse
converts JSX and a flexbox subset of CSS to PNG via Satori and Resvg, works in
Route Handlers and opengraph-image.tsx, and needs no react-dom/server.
Its limits are real (no CSS grid, 500 KB bundle including fonts), but for
badges, OG cards and previews it removes the whole problem.
Workarounds from the thread you should not ship#
"use client" on the page. The error suggests it, and it does make the
build pass. It also turns your page into a Client Component: react-dom/server
is bundled for the browser, the rendering happens client-side, and every
import in that file joins the client graph. If the page touched a database or
a secret, you now have a
"window is not defined"
class of problem in reverse. Do not use it to silence this error.
import ReactDOMServer from 'react-dom/server.browser'. This bypasses the
check because the check matches the react-dom/server specifier. On the test
project it compiled and rendered on both Next.js 15.5 and 16.3. It is still a
poor default: @types/react-dom has no declaration for that path (you need
// @ts-ignore), and you are relying on the browser build behaving like the
Node one for your use case. Reach for it only if a dependency forces a static
import and you cannot change it.
next/dist/compiled/react-dom/cjs/react-dom-server-legacy.browser.production.
Two comments import Next.js's vendored React directly. Internal paths carry no
compatibility promise and have moved between minor versions; a routine
next upgrade will break the build with a
Module not found error that looks
unrelated to this one.
Troubleshooting the follow-up errors#
Error: react-dom/server is not supported in React Server Components.
This is the React stub, not the Next.js check. Next.js 14 compiled route
handlers in the same server-only bundler layer group as Server Components (an
app-route-handler layer that no longer exists in 15 and 16), which matches
the thread: dynamic imports that worked in early 2023 started throwing this at
runtime in 2024. Upgrade to Next.js 15 or later, where the tests above pass,
and keep the dynamic import inside a Route Handler.
Element type is invalid: expected a string ... but got: object.
Reported in the thread when passing a component with props into
renderToStaticMarkup from server code. The usual cause is passing a client
reference (a component imported from a "use client" file) or an async
Server Component into the string renderer. react-dom/server cannot render
either. Render only plain synchronous components, and import them from a file
without the "use client" directive.
Hydration warnings after adding dangerouslySetInnerHTML. If you inject
the rendered string back into a page, the HTML must be deterministic between
server and client. Dates, random IDs and locale-dependent formatting inside
the string are the classic sources; the
hydration failed checklist covers each
one.
Production notes#
- Feeds and sitemaps rendered from JSX should carry
Cache-Controlheaders or arevalidateexport; arenderToStaticMarkupcall per request is cheap, but the data fetch behind it usually is not. - Keep
react-dom/serverusage in one module underlib/so there is a single call site to audit when React changes its server entry points again. - If the string is an email, render it in the handler that sends it, not in the page that triggers it. Like the cookies() should be awaited change, this is a place where Next.js 15 assumes request-scoped work lives in handlers.
Summary#
The error is the App Router refusing to run a second React renderer inside
the first one. In order of preference: return JSX; if you truly need an HTML
string, load react-dom/server with await import() inside a Route Handler;
if the output is an image, use ImageResponse. Skip "use client" and
internal next/dist paths, and expect the React-level "not supported in React
Server Components" message on anything older than Next.js 15.
Related#
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix Dynamic Server Usage Error in Next.js App Router
Next.js throws this when a route it wants to render at build time calls a dynamic function — cookies(), headers(), searchParams, or a no-store fetch. The fix is either opt the route into dynamic rendering, or remove the request-time dependency if it should be static.
Fix "cookies() should be awaited" Error in Next.js 15
Next.js 15 broke synchronous `cookies().get()`. Every server-side call must now `await cookies()` first. Here's the precise migration — App Router pages, route handlers, Server Actions, and Supabase SSR — plus the codemod that fixes 90% of call sites automatically.
Fix Next.js revalidatePath Not Working in Server Actions
Your Server Action mutates data but the page shows stale values until you hard-refresh. `revalidatePath` is one of those APIs that "succeeds" while doing nothing. Here are the six reasons it no-ops, with the exact fix for each — including the one nobody tells you about: `dynamic = 'force-static'`.
Browse by Topic
Find stories that matter to you.
