Next.js Force Client-Side Rendering: "use client" Doesn't, and the Build Shows It
"use client" still prerenders on the server. A probe build shows what ssr: false emits instead, and how useOwnedProducts keeps per-viewer UI off the static HTML.
"use client" does not force client-side rendering in Next.js: the component is still prerendered to HTML on the server and then hydrated. To skip the server render, load it with next/dynamic and { ssr: false } from inside a Client Component, or defer the work until after mount with useEffect. A build of a probe page on this repo proved the difference.
What "force client-side rendering" actually means
Two different things get called by that name, and they have different fixes.
| You want | Mechanism | Server HTML contains |
|---|---|---|
| Interactivity (state, handlers) | "use client" | The component's full markup |
| The component to render only in the browser | dynamic(..., { ssr: false }) | Its loading fallback only |
| One value that differs per visitor | useEffect after mount | The signed-out or default state |
| The whole route rendered per request | Dynamic rendering (cookies, headers) | Fresh HTML, still server-made |
Most people asking this question want the second or third row. They hit a hydration warning or a window is not defined error and assume the directive was supposed to prevent it. It does not.
The experiment: what each option emits
To avoid guessing, a throwaway route was added to this repo, built with npm run build, inspected, and deleted. It rendered two components:
// Probe.tsx
"use client";
import { useEffect, useState } from "react";
export default function Probe({ label }: { label: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return <p>PROBE-{label}-{mounted ? "mounted" : "prerender"}</p>;
}
// ClientOnly.tsx
"use client";
import dynamic from "next/dynamic";
const Probe = dynamic(() => import("./Probe"), {
ssr: false,
loading: () => <p>PROBE-skeleton</p>,
});
export default function ClientOnly() {
return <Probe label="dynamic" />;
}
The page rendered <Probe label="plain" /> and <ClientOnly />. The prerendered .next/server/app/csr-probe.html contained:
<p>PROBE-<!-- -->plain<!-- -->-<!-- -->prerender</p>
<p>PROBE-skeleton</p>
BAILOUT_TO_CLIENT_SIDE_RENDERING
| Component | In the server HTML | After hydration |
|---|---|---|
"use client" only | Full text, in its prerender state | mounted |
dynamic with ssr: false | The loading skeleton, plus a BAILOUT_TO_CLIENT_SIDE_RENDERING marker | The component |
So a plain Client Component runs on the server too. React splits its text with <!-- --> comment nodes, which is also why a naive grep for the whole string finds nothing.
Where the real code already does this
The honest case for client-only rendering is data that depends on who is looking. src/lib/useOwnedProducts.ts decides whether a template card shows a download menu for an owner. The page is static, so the server cannot know the viewer. The hook starts in a neutral state and resolves after mount:
const [state, setState] = useState<OwnedProducts>({
status: COMMERCE_ENABLED ? "loading" : "ready",
...NOBODY,
});
useEffect(() => {
if (!COMMERCE_ENABLED) return;
let cancelled = false;
void loadOwnership().then((result) => {
if (!cancelled) setState({ status: "ready", ...result });
});
return () => {
cancelled = true;
};
}, []);
The lookup is memoized at module scope, because a templates grid mounts a dozen cards and each would otherwise ask the same question. It also checks hasAuthCookie() first, so an anonymous visitor or a crawler never downloads the roughly 68 KiB auth client. The hook's own comment states the limit: it is "a rendering convenience, never an authorization decision". Forcing the output client-side only changes what is drawn; /api/download still answers 403.
This pattern keeps the SEO surface intact. The server HTML is the signed-out card, which is what a crawler should index, and the owner-only controls appear after hydration.
Choosing the mechanism
| Case | Use | Cost |
|---|---|---|
| Per-viewer UI on a static page | useEffect + state | Brief flash of the default state |
Library that touches window at import time | dynamic(..., { ssr: false }) | No server HTML for that part |
| A widget below the fold | dynamic with ssr: false, plus a fixed-height loading | Layout shift if the fallback is the wrong size |
| A whole page behind a login | Fetch in the client; keep the shell static | Crawlers see only the shell |
| Content you want indexed | Do not force it client-side | Crawlers may see only a loading state |
The Next.js docs shipped in node_modules/next/dist/docs/01-app/02-guides/lazy-loading.md state the constraint: ssr: false only works for Client Components and raises an error if used in a Server Component. That is why the probe wrapped dynamic in its own "use client" file.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ssr: false is not allowed with next/dynamic in Server Components | dynamic called from a Server Component | Move the call into a file marked "use client" |
window is not defined during build | Browser API read during prerender | Read it in useEffect or load the component with ssr: false |
| Hydration mismatch warning | Initial render read window, localStorage or Date.now() | Render a neutral state first, set the real value in an effect |
| Content missing from view-source | It is client-only by design | Keep indexable text out of client-only components |
| Layout jumps when the component appears | loading fallback has a different height | Give the fallback the final dimensions |
| One request per card for the same data | State fetched in each component | Memoize one promise at module scope, as useOwnedProducts does |
Frequently asked questions
Does "use client" disable server-side rendering?
No. It marks a module boundary for the bundle. The component is still rendered to HTML on the server, and then hydrated in the browser.
How do I render a component only in the browser in the App Router?
Wrap it with next/dynamic and { ssr: false } inside a Client Component. The server HTML then contains only the loading fallback.
Is client-side rendering bad for SEO? It can be. The Next.js docs warn that some crawlers may not run JavaScript and would then see only a loading state. Keep content you want indexed in the server HTML.
Can I make a whole route client-rendered?
Not with a single flag that this build documents. The common route is a static or server shell that renders one ssr: false Client Component for the app body.
Templates in this post
ASoc Ignite is an AI-app marketing site with a capability showcase and a projects portfolio. ASoc Iris is a computer-vision studio site with case studies and pricing. ASoc Keystone is a mortgage-lender marketing site with an eligibility checker and a quote funnel.
