diff --git a/src/internal/portal/__tests__/portal.ssr.test.tsx b/src/internal/portal/__tests__/portal.ssr.test.tsx
new file mode 100644
index 0000000..0e9fe18
--- /dev/null
+++ b/src/internal/portal/__tests__/portal.ssr.test.tsx
@@ -0,0 +1,31 @@
+/**
+ * @jest-environment node
+ */
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+
+import Portal from '../index';
+
+// The probe span must be part of the server markup, otherwise it appears as an
+// added element during hydration and React discards the whole subtree.
+// See https://github.com/cloudscape-design/component-toolkit/pull/214
+test('renders the probe span so that server and client markup match', () => {
+ const content = renderToStaticMarkup(
+
+ Hello!
+
+ );
+ expect(content).toBe('');
+});
+
+test('does not render portal children on the server', () => {
+ const content = renderToStaticMarkup(
+
+ Hello!
+
+ );
+ expect(content).not.toContain('Hello!');
+});
diff --git a/src/internal/portal/index.tsx b/src/internal/portal/index.tsx
index 8c30f8e..55c0753 100644
--- a/src/internal/portal/index.tsx
+++ b/src/internal/portal/index.tsx
@@ -88,8 +88,9 @@ export default function Portal({
// On the first render, activeContainer is null because the layout effect hasn't
// created it yet. We render a hidden probe span so the effect can read
// ref.current.ownerDocument to discover the correct document (e.g. inside iframes).
- // In SSR there's no document, so we return null to match the previous behavior.
- if (!activeContainer && typeof document !== 'undefined') {
+ // The span is rendered during SSR too, so that the server markup matches the first
+ // client render and hydration does not fail.
+ if (!activeContainer) {
return ;
}