diff --git a/test/ssr.test.tsx b/test/ssr.test.tsx
new file mode 100644
index 0000000..19d5f82
--- /dev/null
+++ b/test/ssr.test.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment node
+//
+// The rest of the suite runs in jsdom, so nothing here verified the
+// README's claim that the component "works out of the box in React Server
+// Components environments ... and touches the DOM only inside effects".
+// This file runs with no DOM at all.
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { renderToString } from 'react-dom/server';
+
+import ImageEditor from '../src';
+
+it('has no DOM in this environment', () => {
+ // Guards the guard: if jsdom leaked in, the test below proves nothing.
+ expect(typeof document).toBe('undefined');
+ expect(typeof window).toBe('undefined');
+});
+
+it('renders to a string on the server without touching the DOM', () => {
+ const html = renderToString(
+
+ );
+
+ expect(html).toContain('id="ssr"');
+});
+
+it('renders on the server with every prop set', () => {
+ // Effects never run on the server, so a prop that reaches the DOM during
+ // render rather than in an effect would throw here.
+ expect(() =>
+ renderToString(
+ {}}
+ onSave={() => {}}
+ onCancel={() => {}}
+ onLoadError={() => {}}
+ onError={() => {}}
+ />
+ )
+ ).not.toThrow();
+});
+
+// The 'use client' banner comes from tsup config, which nothing else
+// checks — a bundler or config change could silently drop it and every
+// existing test would still pass. Runs after `npm run build`; skipped in
+// the React/Node matrix jobs, which do not build.
+const dist = (file: string) => resolve(__dirname, '..', 'dist', file);
+const built = existsSync(dist('index.mjs'));
+
+it.skipIf(!built)('ships the "use client" directive in both builds', () => {
+ for (const file of ['index.js', 'index.mjs']) {
+ expect(readFileSync(dist(file), 'utf8').startsWith("'use client';")).toBe(
+ true
+ );
+ }
+});