Fix: react-dom/server Import Error in Next.js App Router
Next.js

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.

8 min read
Fix: react-dom/server Import Error in Next.js App Router

Photo by Dina Badamshina on Unsplash

Import react-dom/server inside an App Router page and the build stops before anything renders:

text
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.js

That 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 async components, no cookies() or headers(), no use 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:

tsx
// 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:

tsx
// 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, no cookies()/headers() calls inside, no Server Actions. Fetch data in the handler first and pass it as props, as above.
  • Use renderToStaticMarkup for feeds, emails and indexes. renderToString adds 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:

tsx
// 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-Control headers or a revalidate export; a renderToStaticMarkup call per request is cheap, but the data fetch behind it usually is not.
  • Keep react-dom/server usage in one module under lib/ 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.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.