React / React Portal / Portals and Server-Side Rendering (SSR)
Home /React /React Portal /Portals and Server-Side Rendering (SSR)

Portals and Server-Side Rendering (SSR)

Portals and Server-Side Rendering (SSR)

React Portals have a specific behaviour in SSR environments (Next.js, Remix, etc.):

  • Portals cannot render during SSR. The target DOM node (e.g.
    document.getElementById('modal-root')) does not exist on the server. Calling it
    during SSR will throw an error or return null.
  • On hydration (client-side takeover), Portals render normally into the target DOM node.

Safe SSR Pattern: Check for browser environment


import { createPortal } from 'react-dom';
import { useState, useEffect } from 'react';

function ClientOnlyPortal({ children, selector }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);  // Only true on the client after hydration
  }, []);

  if (!mounted) return null;  // Render nothing on server

  const container = document.querySelector(selector);
  return container ? createPortal(children, container) : null;
}

// Usage
<ClientOnlyPortal selector="#modal-root">
  <Modal />
</ClientOnlyPortal>

Next.js Specific Note


// In Next.js App Router, mark the portal component as a Client Component
'use client';

import { createPortal } from 'react-dom';
// ...Portal code here

Performance Considerations

Portals are lightweight, they do not add significant overhead. However, here are
performance-related points to keep in mind:

  1. Unnecessary re-renders: The portal component re-renders whenever its parent
    re-renders. Use React.memo for expensive portal content that doesn’t change
    often.
  2. Dynamic container creation: Avoid creating and appending a new DOM node on
    every render. Create the container once in a useMemo or module-level variable.
  3. Conditional rendering: Return null instead of leaving an
    empty Portal mounted when the UI is not visible. This frees React from updating the portal
    subtree.
  4. Event listeners: Always clean up event listeners inside useEffect
    return functions to prevent memory leaks when portals unmount.

Stable Container Pattern


import { createPortal } from 'react-dom';
import { useMemo, useEffect } from 'react';

function usePortalContainer(id) {
  return useMemo(() => {
    let el = document.getElementById(id);
    if (!el) {
      el = document.createElement('div');
      el.id = id;
      document.body.appendChild(el);
    }
    return el;
  }, [id]); // Created once per id value
}

function DynamicPortal({ children }) {
  const container = usePortalContainer('dynamic-portal');

  useEffect(() => {
    return () => {
      // Optionally remove container when component fully unmounts
      // document.body.removeChild(container);
    };
  }, [container]);

  return createPortal(children, container);
}