Skip to content
Astro best practices 4 min read

Common Pitfalls & Gotchas

Astro’s model is different enough from traditional SPA frameworks that even experienced developers stumble on the same handful of issues. Most pitfalls trace back to one root cause: forgetting that Astro ships zero JavaScript by default and only hydrates the islands you explicitly opt in. This page catalogs the mistakes that show up most often in real projects, why they happen, and how to fix them.

Over-hydrating components

The single most common mistake is slapping client:load on everything. Astro renders every component to static HTML at build time, so a component that only displays data needs no client directive at all. Adding one ships an entire framework runtime to the browser for markup that never changes.

---
import Price from '../components/Price.astro';
import AddToCart from '../components/AddToCart.tsx';
---
<!-- Static: no JS shipped. Correct. -->
<Price amount={29.99} />

<!-- Interactive island: only this needs hydration -->
<AddToCart client:visible sku="ABC-123" />

Reach for the lightest directive that works. Prefer client:visible or client:idle over client:load, and only hydrate the leaf component that actually handles events rather than its static parent wrapper.

Gotcha: A .astro component can never take a client:* directive — those only apply to framework components (React, Vue, Svelte, etc.). Astro components are always server-rendered and stripped of any inline <script> unless you use a proper <script> tag, which Astro bundles separately.

Passing non-serializable props to islands

When you hydrate a framework component, Astro serializes its props to JSON and embeds them in the HTML so the client can rehydrate. This means props must be JSON-serializable. Functions, class instances, Date objects, Map/Set, and Symbols are silently dropped or corrupted.

---
import Timer from '../components/Timer.tsx';
const onTick = () => console.log('tick'); // ❌ cannot serialize
const startedAt = new Date();             // ❌ becomes a string
---
<!-- These will NOT survive the trip to the client -->
<Timer client:load onTick={onTick} startedAt={startedAt} />

Pass primitives and plain objects instead, and reconstruct anything richer on the client:

---
import Timer from '../components/Timer.tsx';
const startedAtIso = new Date().toISOString(); // ✅ string survives
---
<Timer client:load startedAtMs={Date.parse(startedAtIso)} />

Define behavior (callbacks, event handlers) inside the island, not in the .astro parent.

Style scoping surprises

<style> in a .astro file is scoped to that component by default — Astro rewrites your selectors with a hashed attribute. This trips people up in two ways: styles unexpectedly don’t leak into children, and global rules don’t apply.

You wantUseNotes
Styles only this component<style>Default, scoped via hash attribute
Style a child component’s markup:global(...) or <style is:global>Scoping won’t pierce component boundaries
Truly global (resets, fonts)<style is:global>Or import a global CSS file
Dynamic value from frontmatterdefine:vars={{ color }}Injects CSS custom properties
---
const accent = '#6d28d9';
---
<div class="card"><slot /></div>

<style define:vars={{ accent }}>
  .card { border-left: 4px solid var(--accent); }
  /* Reach into a child component's DOM explicitly */
  .card :global(.child-title) { color: var(--accent); }
</style>

SSR vs static confusion

Astro can render a page at build time (static, the default) or on each request (server / on-demand). The pitfall is assuming Astro.request, cookies, or dynamic redirects work on a static page — they don’t, because there is no request when the HTML is built.

---
// This only works when the page is server-rendered
export const prerender = false; // opt this page into on-demand rendering

const country = Astro.request.headers.get('x-country') ?? 'US';
const session = Astro.cookies.get('session')?.value;
if (!session) return Astro.redirect('/login');
---
<p>Serving content for {country}</p>

Set an adapter in astro.config.mjs and mark per-page intent with prerender:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'static',                 // static by default
  adapter: node({ mode: 'standalone' }), // required for any on-demand page
});

With output: 'static', individual pages opt into SSR via export const prerender = false. With output: 'server', the inverse applies — pages are dynamic unless you set prerender = true.

Tip: Build the site and inspect dist/. If a page you expected to be dynamic emitted an .html file, it was prerendered — check your prerender flag and adapter.

Client scripts and the directive trap

Plain <script> tags in .astro files are processed and bundled by Astro, run once, and are not re-run on view transitions. If you wire up DOM listeners in a script, re-initialize them on the astro:page-load event so they survive client-side navigation.

<button id="like">Like</button>
<script>
  function init() {
    document.getElementById('like')
      ?.addEventListener('click', () => console.log('liked'));
  }
  document.addEventListener('astro:page-load', init);
</script>

Output:

liked

Best Practices

  • Default to no client directive; add hydration only to genuinely interactive leaf components.
  • Choose the lightest directive (client:visible / client:idle) and avoid client:load for below-the-fold content.
  • Keep island props JSON-serializable — pass primitives, reconstruct rich objects on the client.
  • Remember <style> is scoped; use :global() or is:global deliberately for shared styles.
  • Decide per page whether it needs SSR, set prerender explicitly, and verify the output in dist/.
  • Re-bind DOM event listeners on astro:page-load so they survive view transitions.
  • Never expect a client:* directive to work on a .astro component — wrap interactivity in a framework component instead.
Last updated June 14, 2026
Was this helpful?