Astro Best Practices
Astro rewards a particular way of building: ship HTML, add JavaScript only where interaction demands it, and let the content layer do the heavy lifting. The framework’s defaults already push you toward fast, accessible sites — the best practices below are mostly about not fighting those defaults. This page is the orientation for the rest of this section: a tour of the principles that separate an idiomatic Astro project from one that has quietly turned into a slow single-page app.
Embrace zero JavaScript by default
Astro renders every component to static HTML at build (or request) time and ships no client-side JavaScript unless you explicitly ask for it. This is the single most important mental model: a component is static until you add a client:* directive. Treat every directive as a cost you have chosen to pay.
---
// src/pages/index.astro
import Hero from "../components/Hero.astro";
import Counter from "../components/Counter.jsx";
---
<Hero title="Welcome" /> <!-- 0 KB JS: pure HTML -->
<Counter client:visible /> <!-- hydrates only when scrolled into view -->
Reach for a UI framework (React, Vue, Svelte) only for genuinely interactive widgets. Static content — headings, cards, navigation, marketing copy — should be plain .astro components so it contributes nothing to the bundle.
A common anti-pattern is wrapping a whole page in a framework component “for convenience.” Doing so hydrates everything and erases Astro’s advantage. Keep islands small and leaf-level.
Choose hydration deliberately
When an island does need JavaScript, the directive controls when it loads. Picking the right one is a per-component decision driven by how soon the user can interact with it.
| Directive | When it hydrates | Best for |
|---|---|---|
client:load | Immediately on page load | Above-the-fold, must-be-instant UI |
client:idle | When the main thread is idle | Important but not first-paint critical |
client:visible | When scrolled into the viewport | Below-the-fold widgets, carousels |
client:media | When a media query matches | Mobile-only or desktop-only UI |
client:only | Client-side only, never SSR’d | Components that can’t render on the server |
Default to client:visible for anything not visible on first paint — it keeps the initial main thread free.
Model content with collections
Don’t scatter Markdown around src/pages/. Use content collections so every entry is type-checked against a Zod schema, gets autocompletion, and fails the build on bad data rather than shipping a broken page.
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
tags: z.array(z.string()),
}),
});
export const collections = { blog };
Query collections with the typed getCollection API, and filter logic in one place:
---
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
Structure components for reuse
Keep a clear boundary between layouts, page components, and UI components. Layouts own the <html> shell and shared <head>; pages compose data and layouts; components stay small and prop-driven. Type your props so misuse is caught at build time.
---
interface Props {
heading: string;
level?: 1 | 2 | 3;
}
const { heading, level = 2 } = Astro.props;
const Tag = `h${level}` as const;
---
<Tag class="section-title">{heading}</Tag>
<slot />
Build accessible, semantic HTML
Because Astro outputs real HTML, accessibility is mostly a matter of writing the right elements: use <button> for actions, <a> for navigation, label every form control, and respect heading order. The compiler will even warn you about some accessibility issues during development. Static-first rendering also means screen readers and search engines get full content without waiting on JavaScript.
Verify what you ship
Use the build output to confirm your discipline is holding. After astro build, inspect the generated JS — pages that should be static should produce no per-page client bundle.
astro build
Output:
12:04:31 [build] 14 page(s) built in 1.83s
12:04:31 [build] Complete!
dist/_astro/Counter.Bqf3k1.js 1.21 kB │ gzip: 0.68 kB
If a page you expected to be static is shipping JavaScript, an island somewhere is over-hydrating — trace it back to a stray client:* directive.
Best Practices
- Keep pages static by default; add
client:*directives only to genuinely interactive leaf components. - Prefer
client:visibleoverclient:loadfor anything below the fold to protect the initial main thread. - Define content in typed collections with Zod schemas so bad data fails the build, not production.
- Separate layouts, pages, and small prop-driven components, and type every
Propsinterface. - Write semantic, labeled HTML — Astro’s HTML-first output makes accessibility nearly free.
- Inspect the build output to catch islands that over-hydrate and ship unexpected JavaScript.