Skip to content
Astro best practices 4 min read

Minimize Client JavaScript

Astro renders every component to static HTML at build time and ships zero JavaScript by default. That single default is the most powerful performance feature in the framework, but it only pays off if you resist the temptation to hydrate everything. The goal of this page is simple: treat client-side JavaScript as a cost you opt into deliberately, not a baseline you start from. Every kilobyte you do not ship is a kilobyte your users never download, parse, or execute.

Start from static HTML

Most of a typical page — headings, prose, images, navigation, footers, marketing copy — is purely presentational. It needs no runtime JavaScript at all. In Astro, a component written without any client:* directive renders to HTML and disappears from the client bundle entirely.

---
// src/components/PriceTable.astro
const plans = [
  { name: "Hobby", price: "$0" },
  { name: "Pro", price: "$19" },
  { name: "Team", price: "$49" },
];
---
<table>
  <thead>
    <tr><th>Plan</th><th>Price</th></tr>
  </thead>
  <tbody>
    {plans.map((p) => (
      <tr><td>{p.name}</td><td>{p.price}</td></tr>
    ))}
  </tbody>
</table>

This component runs once on the server. The browser receives finished HTML — no framework runtime, no hydration, no client bundle. That is the default you should aim to keep for as much of the page as possible.

Even a UI framework component (React, Vue, Svelte) renders to static HTML when you omit the client:* directive. The framework runtime is only shipped for components you explicitly hydrate.

Prefer islands over whole-page apps

When interactivity is genuinely required — a search box, a cart widget, a carousel — isolate it into a small island. An island is a self-contained interactive component surrounded by static HTML. Only the island’s code and its framework runtime ship to the browser; the rest of the page stays static.

---
// src/pages/index.astro
import SearchBox from "../components/SearchBox.jsx";
import Hero from "../components/Hero.astro";
---
<Hero />
<!-- Only this component hydrates; Hero stays pure HTML -->
<SearchBox client:idle />

Avoid wrapping the entire page in one giant interactive component. A single client:load at the root re-introduces the “hydrate everything” cost you are trying to avoid.

Choose the cheapest hydration directive

When you do hydrate, the directive controls when the JavaScript loads and runs. Cheaper directives defer work off the critical path.

DirectiveWhen it hydratesUse for
client:loadImmediately on page loadAbove-the-fold, instantly-needed UI
client:idleWhen the main thread is idleLow-priority widgets
client:visibleWhen scrolled into the viewportBelow-the-fold components
client:mediaWhen a media query matchesMobile-only or desktop-only UI
client:onlyClient-side only, skips SSRComponents that cannot render on the server

Reach for client:load only when interactivity must be ready before the user can act. For anything below the fold, client:visible defers the cost until it is actually seen.

---
import Comments from "../components/Comments.svelte";
import Newsletter from "../components/Newsletter.vue";
---
<!-- Far down the page: load only when scrolled to -->
<Comments client:visible />
<!-- Non-critical: wait for an idle moment -->
<Newsletter client:idle />

Replace JavaScript with platform features

A surprising amount of interactivity needs no framework at all. Modern HTML and CSS cover many cases that developers reflexively reach for JavaScript to solve.

  • Use <details>/<summary> for accordions and disclosure widgets.
  • Use the native popover attribute and :popover-open for menus and tooltips.
  • Use CSS scroll-snap for carousels instead of a slider library.
  • Use a <form> with a real action for submissions that work without hydration.

For the small glue that remains, a tiny inline <script> in an .astro file is bundled and optimized by Astro without pulling in any framework runtime:

<button id="toggle">Toggle theme</button>
<script>
  const btn = document.querySelector("#toggle");
  btn?.addEventListener("click", () => {
    document.documentElement.classList.toggle("dark");
  });
</script>

Measure what you ship

Verify your assumptions with a production build. Astro reports per-route output and you can inspect the emitted assets directly.

npm run build

Output:

12:04:51 ▶ src/pages/index.astro
12:04:51   └─ /index.html (+8ms)
12:04:51 ✓ Completed in 412ms.

12:04:51 [build] 3 page(s) built in 1.18s
12:04:51 [build] Complete!

A route with no islands produces only HTML and CSS — no .js chunks. If you see JavaScript bundles you did not expect, an unintended client:* directive or a client:only import is the usual culprit. Pair this with a Lighthouse run to confirm the JavaScript execution time stays near zero on static routes.

Best Practices

  • Default to static .astro components and add client:* only where interactivity is truly required.
  • Keep islands small and isolated; never hydrate the whole page through a single root component.
  • Choose the lowest-cost directive available — prefer client:visible and client:idle over client:load.
  • Solve interactivity with native HTML/CSS (<details>, popover, scroll-snap) before reaching for a framework.
  • Inspect each production build for unexpected .js chunks and treat them as regressions.
  • Use client:only sparingly — it ships JavaScript and skips server rendering, hurting both performance and SEO.
Last updated June 14, 2026
Was this helpful?