Astro 4 in Practice: Dev Toolbar, Content Collections, View Transitions and Deployment
Key takeaways
Astro 4 keeps a static-first architecture and adds the Dev Toolbar, i18n routing, and steady improvements to Content Collections and View Transitions. This post walks through build settings, type-safe content, page transitions, dark mode, hybrid rendering, and deployment on major hosting platforms, and notes what changes when the same setup moves to Astro 5.
Key points
Astro is a framework for content-centric websites that ships HTML by default and adds JavaScript only to the interactive “islands” you mark. Astro 4, released in December 2023, kept that model and focused on developer experience: a Dev Toolbar in the browser, internationalized routing, and incremental improvements to Content Collections and View Transitions, both of which already existed in Astro 2 and 3. This post covers build settings, type-safe content, page transitions, dark mode, hybrid rendering, and deployment on Vercel, Netlify, and Cloudflare Pages, using the Astro 4 APIs. The last section lists what changes in Astro 5.
What Astro 4 added
| Area | Change in the 4.x line |
|---|---|
| Developer experience | Dev Toolbar overlay in astro dev, with an audit app for common accessibility and performance mistakes; apps can be added by integrations |
| Internationalization | i18n routing (i18n config with locales and a default locale), introduced as experimental in 4.0 and stabilized later in 4.x |
| View Transitions | Form submissions handled by the router, transition:persist-props (4.5), and other lifecycle refinements |
| Content | Content Collections from Astro 2 remain the main API; the loader-based Content Layer arrived behind experimental.contentLayer in 4.14 |
The ideas carry into Astro 5, but several APIs were renamed there, which is why examples written for one version often fail on the other.
Project setup
npm create astro@latest
create astro installs the current major version. To stay on Astro 4, pin it in package.json (for example "astro": "^4.16.0") along with the matching major versions of any official integrations and adapters, since those are released in lockstep with Astro majors.
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
}
}
astro build writes the production output to dist/, and astro preview serves that output locally so you can check it before deploying.
Dev Toolbar
The Dev Toolbar is an overlay shown only while astro dev is running; it is not part of production builds. Its built-in apps let you highlight the islands on the page and see which client:* directive each one uses, and run an audit that flags issues such as images without alt text or interactive elements without accessible names. Integrations can register their own toolbar apps. Highlighting islands is the quickest way to spot components hydrated with client:load that could use client:visible or no directive at all.
If the overlay gets in the way, it can be disabled per project with devToolbar: { enabled: false } in the config or per user with astro preferences disable devToolbar.
Build settings
Output mode and adapters
With output: 'static' (the default), every page is rendered to HTML at build time and can be served from any CDN. output: 'server' renders pages on request and needs an adapter for the target runtime (@astrojs/node, @astrojs/vercel, @astrojs/netlify, @astrojs/cloudflare). Astro 4 also has output: 'hybrid', described below.
Astro uses Vite internally, and Vite options can be passed through the vite key in astro.config.mjs. Vite’s defaults (CSS minification, code splitting) are already production settings, so most sites need no changes here; customize only after measuring a specific problem.
Images
The <Image /> component from astro:assets optimizes local images at build time: it converts them to WebP by default, resizes them, and writes width and height attributes so the browser reserves space before the image loads, which avoids layout shift.
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image src={hero} alt="Hero image" width={1200} loading="eager" />
For an imported image, Astro knows the original dimensions, so giving only width keeps the aspect ratio. The hero image of a page should not be lazy-loaded, since it is usually the Largest Contentful Paint element.
Keeping client JS small
Framework components render to static HTML unless they have a client:* directive. Adding client:load everywhere ships and runs JavaScript for components that may never be interacted with; client:visible and client:idle defer that work.
---
import Counter from '../components/Counter.tsx';
---
<!-- Hydrate only once it enters the viewport -->
<Counter client:visible />
Content Collections
In Astro 4, each collection is a folder under src/content/, and src/content/config.ts declares the collections with a Zod schema. Frontmatter that does not match the schema fails the build with the file name and the field, instead of producing a broken page.
src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content', // Markdown/MDX; use 'data' for JSON/YAML
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
level: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
}),
});
export const collections = { blog };
z.coerce.date() accepts both unquoted YAML dates and quoted strings, which avoids a common error when different authors write dates differently.
Querying and rendering
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.slug}/`}>{post.data.title}</a></li>
))}
</ul>
---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<h1>{post.data.title}</h1>
<Content />
In Astro 4 a content entry has both id (the file path including the extension, such as guides/intro.md) and slug (the URL-friendly form, guides/intro). Use slug for URLs and make the list page and the dynamic route build links the same way; when they disagree, links 404 only in production builds. getEntry('blog', slug) loads a single entry.
MDX content
With the @astrojs/mdx integration, posts can import components and place them between paragraphs. The same island rules apply: a component inside MDX is static HTML unless it has a client:* directive.
---
title: 'Sample MDX Post'
description: 'Using a component inside MDX.'
pubDate: '2024-06-28'
---
import Chart from '../../components/Chart.tsx';
## Live example
The chart below only hydrates after it scrolls into view.
<Chart client:visible />
Each different interactive component used across posts adds its own client bundle, so it pays to keep a small set of shared components (one chart wrapper, one code playground) rather than writing a new one per post.
View Transitions
Adding the <ViewTransitions /> component to a layout turns on Astro’s client-side router: same-origin links are intercepted, the next page is fetched, and the DOM is swapped inside a view transition. Browsers without the View Transitions API get a fallback animation.
---
import { ViewTransitions } from 'astro:transitions';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
Animations and persistence
Transition directives go on the elements being animated, not on links. transition:animate picks the animation for an element, and transition:name pairs an element on the old page with one on the new page.
<main transition:animate="slide">
<slot />
</main>
transition:persist keeps an element’s existing DOM instead of replacing it, which is useful for a sidebar with scroll position, an audio player, or an island with state:
<aside transition:persist="sidebar">
<nav>...</nav>
</aside>
Scripts after navigation
Because the page is no longer reloaded, bundled <script> tags run only once, on the first page load. Code that binds event listeners or reports page views must run on the astro:page-load event, which fires after the first load and after every navigation:
<script>
document.addEventListener('astro:page-load', () => {
// bind listeners, send a page view
});
</script>
A detailed treatment of names, custom animations, fallbacks, and lifecycle events is in Astro View Transitions: transition:name, animate, persist, and Fallbacks.
Dark mode
A common setup uses a dark class on <html>, set from localStorage or prefers-color-scheme by an inline script in <head> so the correct theme is applied before the first paint. With View Transitions there are two extra steps. The router replaces the attributes of <html> with those of the incoming page, which drops the dark class, so the theme must be re-applied on astro:after-swap (which fires before the new page is painted). And the toggle button is a new element after each navigation, so its click listener must be bound on astro:page-load.
---
const title = 'Astro 4 Guide';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<script is:inline>
function applyTheme() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = stored === 'dark' || (!stored && prefersDark);
document.documentElement.classList.toggle('dark', dark);
}
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
</script>
</head>
<body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<button type="button" id="theme-toggle">Toggle theme</button>
<slot />
<script>
document.addEventListener('astro:page-load', () => {
document.getElementById('theme-toggle')?.addEventListener('click', () => {
const dark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', dark ? 'dark' : 'light');
});
});
</script>
</body>
</html>
The is:inline script is not bundled, so it runs synchronously in <head> before the body renders, which is what prevents the flash of the wrong theme. With Tailwind CSS v3, set darkMode: 'class' so the dark: variants follow the class.
Hybrid rendering
output: 'hybrid' in Astro 4 prerenders every page by default, like a static site, and lets individual routes opt into on-demand rendering with export const prerender = false. It needs an adapter. This suits a mostly static site with a few routes that must be computed per request, such as an authenticated dashboard or an API endpoint.
---
// src/pages/account.astro
export const prerender = false;
const session = Astro.cookies.get('session');
---
On-demand routes need their own caching decisions (Cache-Control headers, CDN rules) that static pages get for free, so keep the dynamic surface as small as possible.
Deployment
For a static site, every platform runs npm run build and publishes dist/. Server or hybrid output needs the platform’s adapter.
Vercel
The Astro framework preset sets the build command and output directory. For on-demand rendering, use the Vercel adapter; in Astro 4 (adapter v7) it is imported from a subpath:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'server',
adapter: vercel(),
});
Netlify
Build command npm run build, publish directory dist. For on-demand rendering:
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
export default defineConfig({
output: 'server',
adapter: netlify(),
});
Cloudflare Pages
Static sites publish dist directly. For on-demand rendering, the Cloudflare adapter builds a Worker:
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare(),
});
Server code then runs in the Workers runtime rather than Node.js. Packages that depend on Node built-ins such as fs or net fail at build or run time unless Node compatibility is enabled and the specific modules are supported, and environment variables and bindings are read from the request context instead of process.env.
SEO and structured data
Each page needs a unique <title> and meta description, a canonical URL, and Open Graph tags for social previews. In Astro these usually live in one shared head component that every layout uses, with values passed from page frontmatter. Structured data can be generated in the same place:
---
const { title, description, pubDate, canonicalURL } = Astro.props;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
datePublished: pubDate.toISOString(),
mainEntityOfPage: canonicalURL,
};
---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
set:html is needed because Astro escapes expressions inside <script> otherwise. When moving a blog between platforms or URL schemes, set up redirects and canonical URLs together so search engines consolidate the old and new addresses.
Troubleshooting
| Symptom | What to check |
|---|---|
| Content schema errors at build | Field names and types in src/content/config.ts against the frontmatter of the file named in the error |
| Scripts stop working after navigating | Bundled scripts run once with View Transitions; move setup into astro:page-load |
| Theme resets after navigating | Re-apply the theme class on astro:after-swap |
| Image import errors | astro:assets optimizes imported files from src/; files in public/ are served as-is and must be referenced by URL |
| Cloudflare SSR build failures | Node-only dependencies pulled into server code; the adapter’s Node compatibility settings |
What changes when this setup moves to Astro 5
Most of this post carries over, but three APIs used here were renamed or removed in Astro 5, and they account for most upgrade errors. The <ViewTransitions /> component became <ClientRouter /> (same import path, astro:transitions). output: 'hybrid' no longer exists: the default static output now allows individual routes to opt out with export const prerender = false as long as an adapter is installed, which covers what hybrid mode did. Content Collections moved to the Content Layer, with collections defined through loaders in src/content.config.ts, entries identified by entry.id instead of entry.slug, and render(entry) imported from astro:content instead of entry.render().
If you are starting a new project, start on the current major version and read this post for the concepts. If you are maintaining an Astro 4 site, upgrade one of these three areas at a time and rebuild after each, since a failed build names the file and API that needs changing.