This website you’re reading right now scores 100 across all four Lighthouse categories. Not on a synthetic test page – on the production build, with analytics, fonts, images, and a contact form. We didn’t achieve that by obsessing over performance tricks. We achieved it by picking the right tool: Astro 5.
Most frameworks make you pay a JavaScript tax whether you need interactivity or not. Astro inverts that model. It ships zero JavaScript by default and lets you opt in to client-side code only where interaction actually requires it. For content-heavy sites – marketing pages, blogs, documentation, portfolios – this architecture produces results that React-based frameworks simply can’t match.
The Numbers
Across our last four Astro projects, here are the consistent performance metrics:
- Lighthouse score: 95-100 across Performance, Accessibility, Best Practices, and SEO
- First Contentful Paint: under 0.6s
- Time to Interactive: under 1.2s
- Total Blocking Time: under 100ms
These aren’t aspirational. They’re the baseline you get when your pages ship as static HTML with no runtime framework.
Content Collections: Type-Safe Content at Build Time
Content Collections are the reason we chose Astro over alternatives for content-driven sites. They give you Zod-validated frontmatter, TypeScript-typed query results, and build-time errors when content doesn’t match its schema.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string().max(160), // Enforces SEO-friendly lengths
publishDate: z.coerce.date(),
author: z.string(),
category: z.enum(['AI', 'Cloud', 'Web Development']),
featured: z.boolean().default(false),
draft: z.boolean().default(false),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
The schema definition is where the real value lives. If someone adds a blog post with a missing title or an invalid category, the build fails with a clear error message. No runtime surprises, no silent data issues. Querying collections is equally straightforward: getCollection('blog') returns fully typed results that you can filter and sort at build time – drafts and future-dated posts never make it to production.
Islands Architecture: JavaScript Only Where You Need It
Astro’s defining architectural idea is “Islands.” Most of the page renders as static HTML with zero JavaScript. Interactive components – a search bar, a newsletter form, an analytics widget – load independently as isolated “islands” of interactivity.
The majority of your site ships as pure HTML. Navigation bars, headers, footers, content sections – all rendered at build time with no framework runtime, no hydration cost, no bundle to download. When a component genuinely needs client-side behavior, you hydrate it explicitly with a client directive:
---
import Header from '@/components/Header.astro'; // Static HTML, zero JS
import SearchBar from '@/components/SearchBar.tsx'; // React island
import Analytics from '@/components/Analytics.svelte'; // Svelte island
---
<Header />
<!-- Hydrates immediately -- user needs to interact right away -->
<SearchBar client:load />
<!-- Hydrates when scrolled into view -- zero cost if user never scrolls down -->
<Analytics client:visible />
The client directives give you fine-grained control over when JavaScript loads. client:load is for critical interactive elements like search. client:visible defers hydration until the component scrolls into view – perfect for below-the-fold content. client:idle waits for the browser’s idle period, ideal for non-urgent widgets like comment sections. There’s even client:media for components that only make sense at certain screen sizes, like a mobile hamburger menu.
This is the key insight: on a typical marketing page, maybe 5% of the page needs JavaScript. Astro lets you pay for only that 5%.
View Transitions and Persistent Elements
Astro’s View Transitions API gives you smooth, app-like page transitions without shipping a single-page application framework. Adding <ViewTransitions /> to your base layout turns every navigation into an animated transition. You can customize animations per element using built-in helpers like fade() and slide(), controlling duration and easing independently.
What makes this genuinely powerful is element persistence. You can mark any element with transition:persist, and it survives page navigation without resetting. We use this for audio players, video embeds, and stateful widgets that shouldn’t restart when the user navigates. The whole system works through the browser’s native View Transitions API – no JavaScript framework required.
Server Islands
Server Islands solve a specific but common problem: pages where the shell is static but certain sections require server-side data that’s expensive to compute. Instead of blocking the entire page on a slow database query or API call, you render the shell instantly and let heavy components load asynchronously:
<div>
<h1>Product Details</h1>
<!-- Renders immediately with the static shell -->
<ProductInfo product={product} />
<!-- Fetched server-side after the page loads, doesn't block TTFB -->
<server:defer>
<ProductRecommendations productId={product.id} />
</server:defer>
<server:defer>
<CustomerReviews productId={product.id} />
</server:defer>
</div>
This pattern is particularly effective for e-commerce product pages where the core product info needs to render instantly but recommendations and reviews can stream in. In our experience, Server Islands cut TTFB by 40-60% on pages with heavy dynamic content.
Building a Blog: The Full Picture
A typical Astro blog project follows a clean, predictable structure: content files in src/content/blog/, layouts in src/layouts/, and pages in src/pages/blog/. Dynamic routes use the [...slug].astro pattern combined with getStaticPaths() to generate a static page for every blog post at build time.
The routing layer is minimal. Your getStaticPaths function queries the collection, maps each post to a route, and passes it as props. The page component calls post.render() to get the compiled content and wraps it in a layout. There’s no client-side router, no data fetching waterfall, no hydration mismatch to debug. Because everything resolves at build time, the resulting pages are just HTML files served from a CDN.
MDX support means you can embed interactive components directly in your content – code playgrounds, interactive diagrams, or call-to-action buttons – without leaving the Markdown writing experience.
Image Optimization and SEO
Astro’s built-in <Image> component handles format conversion to WebP, responsive srcset generation, and lazy loading automatically. You import the image, pass it to the component with dimensions, and Astro handles the rest at build time. Remote images work the same way with an inferSize option.
For SEO, static HTML with proper meta tags is the best foundation. We use a shared layout that accepts title, description, and an optional social image, then generates canonical URLs, Open Graph tags, and Twitter Card markup automatically. Combined with the @astrojs/sitemap integration, every page ships with the metadata search engines expect – no runtime JavaScript needed.
Framework Integrations
Astro doesn’t force you into one UI framework. You can use React, Vue, Svelte, or Solid – even in the same project. Each island loads only its own framework runtime, so a React search bar and a Svelte analytics widget can coexist without either framework penalizing the other.
---
import ReactCounter from './ReactCounter.tsx';
import VueComponent from './VueComponent.vue';
import SvelteWidget from './SvelteWidget.svelte';
---
<!-- Each island loads only its own framework runtime -->
<ReactCounter client:load />
<VueComponent client:visible />
<SvelteWidget client:idle />
This is particularly useful when migrating from another framework – you can adopt Astro incrementally without rewriting every component. We’ve used this approach to migrate a Vue marketing site to Astro page by page, keeping existing Vue components functional throughout the transition.
Deployment and Performance Tips
Astro deploys anywhere static files are served. For dynamic features like Server Islands or SSR, you install an adapter – Vercel, Netlify, and Cloudflare Pages are all first-party supported. The base configuration is minimal: a site URL and any integrations you need.
On the performance side, three patterns consistently make the biggest difference. First, prefetching links via data-astro-prefetch attributes, which preloads pages on hover or when visible so navigation feels instant. Second, self-hosting fonts with @fontsource to eliminate external network requests – this alone shaved 200ms off our FCP in one project. Third, Astro’s automatic critical CSS inlining, which embeds above-the-fold styles directly in the HTML so the first paint never waits for a stylesheet download.
When to Choose Astro
Astro is the right tool when content is the product – not the side effect of an interactive application:
- Marketing and corporate websites
- Blogs, documentation, and knowledge bases
- E-commerce product pages (with islands for cart/checkout)
- Portfolio and showcase sites
- Landing pages optimized for conversion
It’s not the right tool for highly interactive applications like dashboards, editors, or real-time collaboration tools. For those, Next.js or a SPA framework is a better fit.
The Takeaway
The web performance conversation has been dominated by “how do we make our JavaScript smaller?” for over a decade. Astro asks a better question: “do we need JavaScript at all?” For content-focused sites, the answer is usually no – and the performance results speak for themselves. Ship HTML, hydrate only what needs to be interactive, and let the platform do what it’s good at.