Astro renders pages as HTML by default and lets you hydrate interactive components where the interface needs them. The architectural benefit is not that every page has zero JavaScript; it is that client-side JavaScript can be limited to the interactions that need it.

The most common mistake is to treat an Astro site like a single-page application and add a framework runtime, global provider and state library before the product needs them.

Start with server-rendered HTML

Use Astro components for content and layout that do not need browser state. This keeps the initial page simple and makes the content available without waiting for client-side hydration.

Add a framework island when a component genuinely needs its framework's state model or ecosystem. Astro supports framework integrations and hydration directives such as client:load, client:idle and client:visible. Choose the directive based on when the interaction must be ready; deferred hydration is not appropriate for every control.

See the Astro Islands guide and client directives.

Use the browser platform for small interactions

A copy button, disclosure, simple filter or reading-progress indicator may need only a few lines of browser JavaScript.

For a filter, render the items and their categories in HTML, then update visibility from a small event handler. Keep controls as real buttons, expose the selected state and ensure that content remains usable if JavaScript fails.

Do not describe a page as "zero JavaScript" if it loads a filter script or hydrated island. Be precise: zero framework hydration, small progressive-enhancement script, or no client JavaScript.

Choose state ownership deliberately

Prefer the simplest state location that fits the behaviour:

  1. URL state for shareable filters, pagination and search terms.
  2. Component state for interaction local to one island.
  3. Browser storage for a user's local preference, with a safe default.
  4. Shared client store or events only when separate islands truly need to coordinate.

A library such as Nanostores can share small reactive values between client components. It is an option, not a requirement. A custom event or URL parameter may be clearer for a one-off interaction.

Never use mutable module-level server state to store data belonging to an individual visitor. Server processes may serve multiple requests; user state must be scoped to the request or stored in an appropriate client or persistence layer.

Example: a small vanilla filter

Use semantic buttons and data attributes:

<div class="filters" aria-label="Filter projects">
  <button type="button" data-filter="all" aria-pressed="true">All</button>
  <button type="button" data-filter="web" aria-pressed="false">Web</button>
</div>

<ul>
  <li data-category="web">Project one</li>
  <li data-category="game">Project two</li>
</ul>

The script should update both visibility and aria-pressed. If the filter is important for navigation or search, represent it in the URL rather than keeping it only in memory.

Keep the hydration boundary visible

For each island, document why it needs client-side execution, when it should hydrate, what data it receives, where its state lives and what happens if JavaScript is unavailable.

Avoid hydrating an entire navigation or page shell to support one small control. Avoid adding a shared store merely because several components exist.

Measure the actual result

Compare production builds, not theoretical framework sizes. Record the JavaScript transferred for the route, the number of hydrated islands, interaction readiness and Core Web Vitals under a consistent test setup.

Do not claim a universal performance score or fixed bundle saving without publishing the build, measurement method and baseline.

Further reading

The useful default is simple: render content on the server, add the smallest interaction that solves the task, and introduce shared state only when the behaviour requires it.