D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Astro `client:idle` Still Renders on the Server? Next's `ssr: false` Equivalent Is `client:only`

· · 9 min read
Astro `client:idle` Still Renders on the Server? Next's `ssr: false` Equivalent Is `client:only`

Above two component lines in my Astro layout sat a comment that sounded perfectly reasonable. It said the terminal and the pet load after the page goes idle, not on load. In Next both were wrapped in next/dynamic with ssr: false to keep them out of the initial bundle, and client:idle was the closest equivalent in Astro. That last sentence was the wrong one, and the mistake could be counted: aria-label="Open terminal" was printed in the raw HTML of all 500 pages on the Astro site, while on the Next production site the count was zero.

This happened on 8 September 2026, in the middle of rebuilding my own site from Next.js 16.2.10 to Astro 7.3.1, with React 19.2.3 in both repos. I split the work into numbered stages. The terminal widget and the pet widget arrived in stage M1 together with the site shell, and the defect only surfaced when the stage M4 checklist was run. The M1 checklist never looked at it.

What Next sent for those two widgets

In the Next repo, the shell component loads the terminal and the pet through next/dynamic with ssr: false. Two other shell widgets, one of them a music player, load the same way. Roughly, with component names and paths replaced:

// Shell component in the Next repo, simplified. Component names and paths replaced.
// ... deferred (client-only, not needed for first paint or
// SEO). Keeps gsap/framer-motion-bound widgets out of the initial bundle so
// mobile hydration/INP stays light.
const TerminalWidget = dynamic(
  () => import("@/components/TerminalWidget").then((m) => m.TerminalWidget),
  { ssr: false }
);
const PetWidget = dynamic(
  () => import("@/components/PetWidget").then((m) => m.PetWidget),
  { ssr: false }
);

The original comment gives the reasons: these widgets are client-only, not needed for first paint or SEO, and the gsap or framer-motion bound ones stay out of the initial bundle so hydration and INP on phones stay light. The dynamic import for the terminal had been in the Next repo since 22 June 2026.

The part that decides what ends up in the HTML is ssr: false. With that option Next skips the component during the server render, and its markup only exists once JavaScript runs in the browser. That is why the Next production HTML mentions Open terminal zero times.

Where that string comes from is no mystery. The terminal component starts its isOpen state as false, and while !isOpen it renders a button with aria-label="Open terminal". The component's initial state is that button, so as soon as it gets rendered on the server, the attribute is printed into the HTML of every page that carries it.

What client:idle actually defers

The stage M1 commit, timestamped 10:46, mounted both components in the Astro shell layout with client:idle. The directive's name is easy to read as "don't touch this component until the browser is idle", but only the client side is deferred. In the Astro 7.3.1 server runtime, the branch that decides an island's HTML only tells only apart from everything else:

// astro 7.3.1, dist/runtime/server/render/component.js, trimmed
if (metadata.hydrate === "only") {
  html = await renderSlotToString(result, slots?.fallback);
} else {
  const componentRenderStartTime = performance.now();
  ({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
    // ...

When the directive is only, the island's HTML is filled from the fallback slot alone. For every other directive, idle, visible, and load included, the component is rendered through the renderer's renderToStaticMarkup. idle only plays a part on the client:

// astro 7.3.1, dist/runtime/client/idle.js, trimmed
if ("requestIdleCallback" in window) {
  window.requestIdleCallback(cb, idleOptions);
} else {
  setTimeout(cb, idleOptions.timeout || 200);
}
 
// only.js
const onlyDirective = async (load) => {
  const hydrate = await load();
  await hydrate();
};
 
// load.js
const loadDirective = async (load) => {
  const hydrate = await load();
  await hydrate();
};

client:idle defers loading and hydration to requestIdleCallback, or to a 200 ms setTimeout when that API is missing and no timeout was given. That is all it defers. The component's markup has already come from the server, so the Open terminal button sits in the HTML from the start and only gains its behavior once the island hydrates.

The last two files also show that on the client, client:only and client:load are identical. Both call load() and then hydrate() right away, without waiting for anything. The difference lives in two other places: the server branch above, and Astro's React renderer, @astrojs/react 6.0.5, which mounts only islands with createRoot and every other island with hydrateRoot. An only island is built from scratch in the browser, because there is no server markup to hydrate.

Astro itself points the same way. Its server rendering error message, for components that cannot avoid touching window or document, recommends the client:only directive. Of all these directives only only skips the server render, so only only is equivalent to ssr: false.

Two lines in the shell layout

The fix commit is timestamped 12:09 the same day. Before and after:

---
// Shell layout in the Astro repo, simplified. Component and persist names replaced.
import TerminalWidget from '../components/react/TerminalWidget.tsx';
import PetWidget from '../components/react/PetWidget.tsx';
---
<!-- stage M1 -->
<TerminalWidget client:idle transition:persist="widget-terminal" />
<PetWidget client:idle transition:persist="widget-pet" />
 
<!-- after the fix -->
<TerminalWidget client:only="react" transition:persist="widget-terminal" />
<PetWidget client:only="react" transition:persist="widget-pet" />

client:only needs the renderer's name, here "react". The component is never rendered on the server, so Astro has to be told which renderer mounts it in the browser. transition:persist stayed untouched, since both components are persisted so the terminal's state and the pet's position survive page changes.

The commit touches one file, 11 lines added and 5 removed, and apart from the two directive lines, what changed is the comment. The new comment says the terminal and the pet are mounted only on the client and never rendered on the server, rejects client:idle as the equivalent of ssr: false, and records that the first pass's reason, "out of the initial bundle", was wrong on one measurable point, the server render above. The same rule is recorded in the repo README under a heading for things that are easy to break without noticing, and the music player in the same shell also uses client:only rather than client:load, for the same reason.

One side effect can be read straight from the directive files. Since only.js is identical to load.js, the terminal and the pet are now loaded and mounted as soon as the page loads, no longer waiting for the browser to go idle. My notes hold no numbers for that difference, so I will not call it cheap or expensive.

Patched, with one limit

The fix is one of the four commits of stage M4. That stage was declared done on 8 September 2026, except for one item that belongs to stage M8, and its checklists, from structure through client navigation, passed on the deployment with zero React errors in the console.

The limit deserves to be stated plainly. My notes contain no recount of aria-label="Open terminal" specifically after the fix, so I am not going to write a fresh zero for the live site. What I have is the M4 checklist passing on the deployment, and a code path in the Astro server runtime that, for the only directive, never reaches renderToStaticMarkup.

The terminal defect was one of three old defects that only surfaced when the M4 checklist was run, and all three are patched. All three passed the checklist of the stage they came from and were only caught by a later stage's checklist that looked at the same thing from another angle. My notes sum it up in one sentence: a stage declared done is not necessarily clean.

Two more directions on the same map

Two other cases from the same site complete the mapping, and it comes down to three rows:

Next.js                                     Astro
server component (no "use client")          no client directive
regular import in one client tree           client:load
next/dynamic with ssr: false                client:only

The first row means a React component without a client directive is fully rendered on the server and ships zero JavaScript, the exact equivalent of a Next Server Component. Its trap comes from the opposite direction. Stage M10 had a "use client" component that set up an IntersectionObserver in useEffect. Without a directive, that component joins the server rendered tree, and in that tree useEffect never runs. Its hidden state is written in CSS behind @media (scripting: enabled), so without the observer the whole content stays pending forever. The general rule I took from it: a client component that removes a hidden state must not be rendered on the server without a replacement.

The second row comes from a bug that only appeared after the domain was switched to Astro on 8 September. The home page section animations sat still and only started after their section had passed half the viewport height. The cause: eight of the nine home sections had been given client:visible in stage M4, the very stage whose checklist caught the terminal defect. Their islands only hydrated when the section entered the viewport, and their ScrollTriggers were only created after the trigger point had passed. At scroll zero, client:visible left 6 of 55 ScrollTriggers standing, while client:load had 55 of 55.

In Next, all nine sections were regular imports in a single React tree that mounted together, so the equivalent is client:load. The fix commit the next day, 9 September, swapped those eight directives, and its message calls client:visible an Astro-specific optimization added in M4 without noticing that it changed behavior. The cost of switching was measured as close to nothing, 94.6 KB against 95.3 KB, because Astro islands are referenced through component-url in the HTML whatever their directive, so the only thing that changes is when an island is fetched. A middle path was tried as well and fell short. Widening rootMargin to 900px only raised the count to 37 of 55, and 1600px added nothing, because some of the triggers are created by nested effects rather than by hydration.

Two different questions

ssr: false answers one question: whether this component is rendered on the server at all. Astro directives other than only answer a different one, namely when the island's JavaScript runs, and in the Astro 7.3.1 server runtime all of them still render the component. On this site I picked client:idle as the closest equivalent and added client:visible as an optimization. Both change when an island comes alive, and neither turned out to match the Next version. So when moving a component over from Next, match those two things separately: whether the component shows up in the server HTML, and when it comes alive.