Astro
Use this playbook to build a content or marketing website with Astro. The result is a static site that ships HTML first. It has a typed component system, one layout per page type, optimized images, one URL per page, complete head metadata, and security headers.
This guidance targets Astro 7. Astro 7 replaced the compiler and the default Markdown processor, so advice for older versions is often wrong. The basis is the Astro 7 documentation, the Astro source and changelog, and GitHub issues that report real failures. Recheck the linked documentation before each Astro major release, and before you start a new site.
The one rule: build every page from components. A page file selects a layout, loads data, and composes components. It holds no styles and no styled markup of its own. When markup appears twice, or when an element carries styles, that markup is a component.
Core model
Section titled “Core model”An Astro site has four layers. Each layer has one job and one invariant.
| Layer | Location | Job | Invariant |
|---|---|---|---|
| Pages | src/pages/ | Map a URL to a layout and components | Holds no <style> block and no styled markup |
| Layouts | src/layouts/ | Own the document shell and the frame for one page type | Only BaseLayout renders <html>, <head>, and <body> |
| Components | src/components/ | Own every piece of repeated or styled markup | Typed props, one root element, accepts class |
| Content | src/content/ and src/content.config.ts | Own prose and structured data | Every entry passes a schema |
These invariants apply to every step:
- Astro renders HTML at build time. A page ships no JavaScript unless a component opts in.
- Astro processes the files in
src/. Astro copies the files inpublic/unchanged. - The host serves the built files. For prerendered pages, the host controls headers, redirects, and trailing slashes. The Astro config and the host config must agree.
- Each page has one URL. The canonical link, the sitemap, internal links, and the host all use that exact URL.
Sources: project structure, islands architecture, trailingSlash.
When to use it
Section titled “When to use it”Use this playbook when all of these conditions apply:
- The site is content-first: marketing pages, landing pages, a blog, case studies, or legal pages.
- Most pages can render at build time.
- Interactivity stays inside one part of a page, for example a menu, a form, or a carousel.
Use another tool when any of these conditions apply:
- Most screens share client state, as in a dashboard or an editor. Use a client application framework.
- The site is mainly technical documentation. Use Starlight, which is built on Astro.
- Most pages need data for each request, such as user accounts. Astro can render on demand with an adapter, but this playbook covers static output only.
Baseline
Section titled “Baseline”- The current Node LTS release. Astro 7 requires Node 22.12 or later.
- Astro 7, installed in the project. Never install Astro globally.
- TypeScript with the
astro/tsconfigs/strictpreset. @astrojs/checkandtypescript@^6as dev dependencies. Pin TypeScript 6, becauseastro checkdoes not support TypeScript 7.@astrojs/sitemap.@astrojs/mdx, only when a content type needs components. See step 6.- Scoped
<style>blocks in components, plus one global stylesheet for design tokens. If the team uses Tailwind, add Tailwind 4 withnpx astro add tailwind. - Cloudflare Workers static assets as the host, with no adapter. The steps name the differences for Netlify and Vercel.
- For agents: the Astro Docs MCP server at
https://mcp.docs.astro.build/mcp. It serves the current documentation.
Commit these files:
- The package lockfile and
.nvmrc. astro.config.mjs,tsconfig.json, andsrc/content.config.ts.public/_headersandpublic/_redirects.- The Wrangler config file.
Never commit these items:
.envfiles, CMS tokens, and API keys.dist/and.astro/.node_modules/and the Astro cache directory.
| Part | Purpose |
|---|---|
astro.config.mjs | The site URL, the URL format, images, fonts, Markdown, CSP, and integrations |
src/content.config.ts | The loader and the schema for each collection |
src/data/site.ts | Site-wide data: the name, the navigation, social links, and contact details |
src/components/ui/ | Primitives: Link, Button, Heading, Container, Section, Card, Prose, Photo, Icon, JsonLd |
src/components/sections/ | Page sections built from primitives: Hero, FeatureGrid, CallToAction |
src/components/site/ | Site chrome: BaseHead, Header, Footer |
src/layouts/BaseLayout.astro | The document shell |
src/layouts/*Layout.astro | One layout per page type |
src/pages/ | Routes |
src/assets/ | Images that Astro optimizes |
public/ | Files that need a fixed URL: favicons and the default OG image |
public/_headers and public/_redirects | Response headers and redirects for the host |
Add these parts only when their problem exists:
src/pages/rss.xml.ts: the site publishes a feed.- A UI framework integration, for example
@astrojs/preact: a component needs client state that a<script>can’t manage. - A host adapter: at least one page renders on demand.
src/├── assets/ # images for astro:assets├── components/│ ├── ui/ # primitives│ ├── sections/ # page sections│ └── site/ # BaseHead, Header, Footer├── content/ # collection entries├── data/ # site.ts and JSON data├── layouts/ # BaseLayout and page-type layouts├── pages/ # routes├── styles/ # global.css with design tokens└── content.config.tspublic/ # _headers, _redirects, faviconssrc/pages/ is the only directory that Astro reserves. The other directories are conventions. Keep them flat. Don’t sort components into atomic-design tiers, because the classification costs time and adds no value.
Astro reserves the file names src/middleware.ts and src/fetch.ts. Don’t use them for other code.
Source: project structure, upgrade to Astro 7.
Build order
Section titled “Build order”1. Create the project
Section titled “1. Create the project”Scaffold with the official CLI. Add integrations with astro add.
npm create astro@latest SITE-NAMEcd SITE-NAMEnpx astro add sitemapnpm install -D @astrojs/check typescript@^6Replace the following:
SITE-NAME: the directory name for the project.
Set the build script to astro check && astro build. astro build does not check types. astro check also generates the types for collections and astro:env.
Extend the strict preset in tsconfig.json. Define path aliases with paths and explicit ./src/ targets. Don’t set baseUrl, because TypeScript 6 deprecates it.
{ "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"], "compilerOptions": { "paths": { "@components/*": ["./src/components/*"], "@layouts/*": ["./src/layouts/*"] } }}Set "engines": { "node": ">=22.12.0" } in package.json. Put the current LTS major version in .nvmrc.
Upgrade Astro and its integrations together with npx @astrojs/upgrade. Don’t enable experimental flags on a production site. An experimental API can break in a patch release.
Astro starts astro dev in the background when an AI agent runs it. Stop the server with astro dev stop. Set ASTRO_DEV_BACKGROUND=0 to keep it in the foreground.
Source: install Astro, TypeScript, CLI reference, experimental flags, build with AI.
2. Set the site URL and the URL format
Section titled “2. Set the site URL and the URL format”Set these keys before you write a link. Every URL in the site depends on them.
import { defineConfig } from 'astro/config';import sitemap from '@astrojs/sitemap';
export default defineConfig({ site: 'https://www.example.com', trailingSlash: 'always', build: { format: 'directory' }, prerenderConflictBehavior: 'error', integrations: [sitemap()],});site: the production origin. The sitemap, the canonical link, and absolute OG URLs need it.trailingSlash: 'always'withbuild.format: 'directory': each page builds toabout/index.htmland lives at/about/. Static hosts serve that form by default. The canonical link, the sitemap, andpaginate()then agree.prerenderConflictBehavior: 'error': the build fails when two routes emit one URL, or when a loader finds two entries with one ID.
Never ship the default trailingSlash: 'ignore'. It lets one page answer at two URLs with two canonical links.
trailingSlash controls only the dev server and on-demand pages. The host decides trailing slashes for prerendered pages. Step 13 makes the host match this config.
Write every internal link in the canonical form, with the trailing slash. Files with an extension are the exception. /rss.xml, /robots.txt, and /sitemap-index.xml answer only without a slash.
Don’t set base unless the site must live under a subpath. With base, import.meta.env.BASE_URL changes form with trailingSlash. Build every internal URL in one helper that reads base and trailingSlash from astro:config/client.
Source: site, trailingSlash, build.format, prerenderConflictBehavior, astro:config.
3. Build the component system
Section titled “3. Build the component system”Build the primitives before the first page. Then build every page from them. This step carries the one rule of the playbook.
Apply the component rule
Section titled “Apply the component rule”Make a component in each of these cases:
- The markup appears in two places, on one page or on two pages.
- An element carries styles that must look the same everywhere, for example a button, a link, or a heading.
- A region of a page has a name, for example a hero, a pricing table, or a testimonial.
- An entry from a collection renders as a card, a list item, or a row.
A page file contains only a layout, data loading, and components. A page file never contains a <style> block. When a page needs new markup, add a section component first, then use it.
Sort components into three folders:
ui/: primitives. Each primitive renders one root element and does one job.sections/: page regions that you compose from primitives.site/: the head, the header, and the footer that every page shares.
Express modes as string-union props, such as variant="primary", not as boolean flags. Boolean flags combine into states that nobody designed. When a component needs more than three mode props, split it into two components.
Source: Astro components, official portfolio example.
Write the props contract
Section titled “Write the props contract”Type every component with interface Props. For a primitive that wraps one element, extend HTMLAttributes for that element. Callers then get every valid attribute, with type checks.
---import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'section'> { tone?: 'default' | 'muted' | 'inverted'; width?: 'prose' | 'wide' | 'full';}
const { tone = 'default', width = 'wide', class: className, ...rest } = Astro.props;---
<section class:list={['section', `section--${tone}`, `section--${width}`, className]} {...rest}> <slot /></section>Follow these rules for every primitive:
- Destructure
classand merge it intoclass:list.classdoes not pass through to a child component automatically. - Spread
...restonto the root element. The spread carries thedata-astro-cid-*attribute, so the scoped styles of the parent reach the child. - Never spread
...restand also set the same attribute. Astro then emits twoclassattributes, and the browser ignores the second one. - Read only
classin a component. Aclass:listfrom the caller arrives asclass. - Reuse the props of a wrapped component with
ComponentProps<typeof Component>fromastro/types. - Use a polymorphic
asprop only to change the element, for example the level of aHeading. For a link or a button, branch onhref.
Source: component props, passing a class to a child component, class:list.
Model regions with slots
Section titled “Model regions with slots”Use the default <slot /> for the body of a component. Use named slots for other regions.
- Put
slot="NAME"on an immediate child of the component. Aslotattribute on a nested element does nothing. - Wrap several elements for one slot in
<Fragment slot="NAME">. - Put fallback content inside
<slot>for an optional region. - Check a region with
Astro.slots.has('NAME')before you render its wrapper element.
Replace the following:
NAME: the name of the slot, for exampleactions.
Astro.slots.has() reports whether the caller passed the slot, not whether it has content. {condition && <p slot="note">…</p>} passes the slot even when condition is false. Put the condition around the whole component, or pass a prop.
Slot names must be static. A slot name that you compute inside .map() fails the build.
Use <slot /> for normal rendering. Use Astro.slots.render() only to post-process the HTML. It renders the slot again on each call.
Source: slots.
Style inside the component
Section titled “Style inside the component”- Write styles in a scoped
<style>block in the component. Astro scopes plain selectors to the component. - Define design tokens as CSS custom properties in
src/styles/global.css: colors, spacing, and the type scale. Import that file once, inBaseLayout. - Style slotted content or rendered Markdown with
:global()inside a wrapper, for example.prose :global(h2). Keep these rules in theProseprimitive only. - Keep
<style>at the top level of the template. Astro doesn’t scope a<style>inside an expression. - Don’t import CSS files into primitives. Astro applies an imported stylesheet on every page that imports the component, even when the component doesn’t render.
Source: styling.
Write markup that the compiler accepts
Section titled “Write markup that the compiler accepts”The Astro 7 compiler is strict. It does not repair HTML.
- Close every element that isn’t a void element. An unclosed tag fails the build.
- Don’t put a block element inside
<p>. The compiler keeps it, and the browser closes the<p>early. - Write
{" "}between inline elements on separate lines. Astro removes the newline between them, so the words join. - Don’t put
onclickor other event attributes on elements. Astro converts attribute values to strings. Use a<script>instead.
Source: upgrade to Astro 7, compressHTML.
Keep site data in one module
Section titled “Keep site data in one module”Put the site name, the navigation, social links, and contact details in src/data/site.ts. Header, Footer, and BaseHead read from it. Never type a navigation link directly in a page.
4. Build the layouts
Section titled “4. Build the layouts”Build one base layout. Then build one layout for each page type. Each page-type layout wraps the base layout.
Build the base layout
Section titled “Build the base layout”BaseLayout.astro is the only file that renders <html>, <head>, and <body>. It does these things:
- Sets
<html lang>. - Imports
global.css. - Renders
BaseHeadwith the metadata props of the page. - Exposes a named
headslot for page additions, such as JSON-LD. - Renders
Header, the default slot inside<main>, andFooter.
---import type { ComponentProps } from 'astro/types';import '../styles/global.css';import BaseHead from '../components/site/BaseHead.astro';import Header from '../components/site/Header.astro';import Footer from '../components/site/Footer.astro';
type Props = ComponentProps<typeof BaseHead>;---
<html lang="en"> <head> <BaseHead {...Astro.props} /> <slot name="head" /> </head> <body> <Header /> <main><slot /></main> <Footer /> </body></html><html> must be the parent of all other elements in the layout. Astro doesn’t add <meta charset> to a Markdown page that uses a layout, so BaseHead must render it.
Add one layout per page type
Section titled “Add one layout per page type”| Layout | Pages | Frame |
|---|---|---|
LandingLayout | Home, product, and campaign pages | Full-width sections, no prose column |
ArticleLayout | Blog posts, case studies, and news | Prose column, title, date, author, table of contents |
LegalLayout | Privacy policy and terms | Prose column, effective date, minimal navigation |
Add a layout when a new page type needs a different frame. Don’t add a layout for one page.
Each page-type layout does three things:
- It accepts the head props plus its own typed props.
- It wraps
BaseLayoutand passes the head props through. - It forwards the
headslot with<slot name="head" slot="head" />.
For a layout that renders a collection entry, type its props from the schema: CollectionEntry<'blog'>['data']. Pass headings from render() as a prop for the table of contents.
The layout frontmatter key works only for .md and .mdx files in src/pages/. Content collections ignore it. Type such a layout with MarkdownLayoutProps from astro.
Source: layouts, nesting layouts, Markdown layouts.
5. Build the head component
Section titled “5. Build the head component”Put all head metadata in src/components/site/BaseHead.astro. Only BaseLayout renders it. Pages pass metadata as layout props. Pages never write <meta> tags.
---import { getImage } from 'astro:assets';import defaultImage from '../../assets/og-default.jpg';
interface Props { title: string; description: string; image?: ImageMetadata; imageAlt?: string; type?: 'website' | 'article'; noindex?: boolean;}
const { title, description, image = defaultImage, type = 'website', noindex = false } = Astro.props;const canonicalURL = new URL(Astro.url.pathname, Astro.site);const ogImage = await getImage({ src: image, width: 1200, height: 630, format: 'jpg' });const ogImageURL = new URL(ogImage.src, Astro.site);---Render the tags in this order:
<meta charset="utf-8">. It must be in the first 1024 bytes of the document.<meta name="viewport" content="width=device-width, initial-scale=1">.<title>and<meta name="description">.<link rel="canonical" href={canonicalURL}>.<meta name="robots" content="noindex">, whennoindexis true.- Open Graph:
og:type,og:urlwith the canonical URL,og:title,og:description,og:image,og:image:alt, andog:site_name. <meta name="twitter:card" content="summary_large_image">. X reads the Open Graph tags for the other values.- The favicons, the sitemap link, and the RSS link.
- One
<Font>for each font. See step 8.
<link rel="icon" href="/favicon.ico" sizes="32x32" /><link rel="icon" href="/icon.svg" type="image/svg+xml" /><link rel="apple-touch-icon" href="/apple-touch-icon.png" /><link rel="sitemap" href="/sitemap-index.xml" />Follow these rules:
- Build every absolute URL from
Astro.site. Withoutsite,Astro.urlgiveslocalhostURLs in a static build. - Give each page a unique title and description. Put the site name at the end of the title, after a delimiter. Google sets no length limit. It truncates the title to the width of the device.
- Make the OG image a 1200 × 630 JPEG. Every social platform accepts JPEG.
- Put
favicon.icoat 32 × 32,icon.svg, andapple-touch-icon.pngat 180 × 180 inpublic/. Google Search doesn’t show SVG favicons. - Don’t add
<meta name="keywords">. Google ignores it.
Keep non-production pages out of search
Section titled “Keep non-production pages out of search”- Rely on the host first. Most hosts send
X-Robots-Tag: noindexon preview URLs, but not on every branch URL. - Set
noindexinBaseHeadfor every other non-production build with a public URL. Gate it on a build-time variable fromastro:env.
Never block a preview in robots.txt. A crawler that can’t fetch a page never sees its noindex rule.
Add structured data through a component
Section titled “Add structured data through a component”Render JSON-LD with a JsonLd primitive. The primitive escapes <, because set:html doesn’t escape its value.
---interface Props { schema: Record<string, unknown>;}
const json = JSON.stringify(Astro.props.schema).replace(/</g, '\\u003c');---
<script type="application/ld+json" set:html={json} />Without the escape, a CMS string that contains </script> ends the element, and the markup after it runs. Pass JsonLd into the head slot of the layout. Test the result with the Google Rich Results Test.
For a multilingual site, add hreflang alternates with getAbsoluteLocaleUrl() from astro:i18n. Each page lists itself, every other language, and x-default.
Source: official blog BaseHead, Astro.site, set:html, Google: canonical URLs, Google: title links, Google: favicons, Google: block indexing, Google: localized versions, Open Graph.
6. Model the content
Section titled “6. Model the content”Put every repeating content type in a content collection. Examples are posts, case studies, team members, testimonials, and FAQ entries. A page never hard-codes data that a collection can hold.
import { defineCollection, reference } from 'astro:content';import { glob, file } from 'astro/loaders';import { z } from 'astro/zod';
const blog = defineCollection({ loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }), schema: ({ image }) => z.object({ title: z.string(), description: z.string(), pubDate: z.coerce.date(), cover: image(), coverAlt: z.string(), author: reference('authors'), draft: z.boolean().default(false), }),});
const authors = defineCollection({ loader: file('./src/data/authors.json'), schema: z.object({ name: z.string(), url: z.url() }),});
export const collections = { blog, authors };Follow these rules:
- Import
zfromastro/zod. Schemas use Zod 4 syntax, for examplez.url()andz.email(). - Declare images with
image(). The path in the frontmatter is relative to the entry file. - Sort the result of
getCollection()yourself. Its order isn’t defined. - Filter drafts and sort entries in one data helper, for example
getPublishedPosts(). Every list page uses it. - Check each
getEntry()result for areference(). A broken reference logs an error, but the build continues. - Route entries with
[...id].astroand string params fromgetStaticPaths(). Render an entry withrender(entry), and passheadingsto the layout.
Choose the file format
Section titled “Choose the file format”Default to .md in a collection. Choose another format only for a reason in this table.
| Format | Choose it when | Never choose it when | Who edits it |
|---|---|---|---|
.md in a collection | The content is prose with frontmatter: posts, case studies, changelogs, and legal text | The body needs a component, or an element must render as a component | Anyone: writers, a Git-based CMS, and developers |
.mdx in a collection | The body needs a small, fixed set of components, or a and h2 must render as components | The authors are untrusted, or the content has no components | Developers and trusted writers |
.mdoc (Markdoc) in a collection | Non-developers need custom components with checked attributes, and raw HTML must stay off | Build time matters on a large collection | Non-developers, through a CMS |
.json or .yaml in a collection | The content is structured records: team members, testimonials, and the copy blocks of a landing page | The content is long prose | Anyone, through a CMS or Git |
.astro page | The page is a designed composition of sections: home, landing, pricing, and campaign pages. Also every route that renders a collection | The page is long prose, or non-developers must change its copy | Developers only |
.md in src/pages/ | One simple page with no siblings. Set layout to a page-type layout | The site has several pages of the same shape | Developers |
The reasons:
.mdis the cheapest and safest format. It builds about twice as fast as.mdx, and it runs no code at build time.- An
.mdxfile is a JavaScript module. A stray<or{in prose fails the build, and the file can run any code at build time. - Markdoc doesn’t use the Rust Markdown processor, so it builds much slower than
.md. - A
.mdor.mdxfile insrc/pages/skips schema validation. Types fromMarkdownLayoutPropsdon’t check values at runtime. - Prose inside
.astrofiles loses spaces between inline elements, and writers can’t edit it safely.
Keep the copy of a landing page in a data collection. The .astro page maps each record to a section component. Writers then change the copy, and the markup stays in components.
Follow these rules in MDX:
- Escape
<and{in prose as\<and\{. - Self-close void elements, for example
<br />. - Write comments as
{/* text */}. An HTML comment fails the build. - Don’t set
mdx({ optimize: true })unless build time requires it. It turns off thecomponentsmapping for every element that isn’t inignoreElementNames.
.md passes raw HTML through, including <script>. Sanitize Markdown from untrusted sources, or use Markdoc, which turns HTML off by default.
After you change the format of an entry, run astro build --force once. A warm content cache can fail the next build.
Source: when not to create a collection, individual Markdown pages, MDX integration, MDX security, Markdoc integration.
Render Markdown through components
Section titled “Render Markdown through components”- Wrap rendered Markdown in the
Proseprimitive.Proseowns the typography for headings, lists, tables, and code. - In MDX, pass components from the route:
<Content components={{ Callout, a: Link }} />. A mapped component must render<slot />. - Don’t import components in each MDX file. An import path breaks when the component moves, and many CMSs reject
import. .mdignores thecomponentsprop. Change element attributes in.mdwith a SätterihastPluginsentry.- Keep the hero image out of the Markdown body. Put it in the frontmatter, and render it in the layout with
priority.
Keep the default Markdown processor
Section titled “Keep the default Markdown processor”Astro 7 processes Markdown with Sätteri by default. GitHub Flavored Markdown, smart punctuation, and heading IDs are built in. Don’t add remark-gfm, remark-smartypants, or rehype-slug.
Sätteri doesn’t run remark or rehype plugins. To keep such a plugin, install @astrojs/markdown-remark and set markdown.processor: unified({ ... }). Otherwise the plugin does nothing, sometimes with no warning. After a change to the Markdown config, search dist/ for the output of each plugin.
Source: content collections, content loader reference, Markdown, MDX.
7. Add images
Section titled “7. Add images”Turn on responsive images for the whole site. They are off by default.
export default defineConfig({ image: { layout: 'constrained', responsiveStyles: true, remotePatterns: [{ protocol: 'https', hostname: 'CMS-HOST' }], service: { config: { avif: { effort: 2 } } }, },});Replace the following:
CMS-HOST: the host name that serves remote images. Remove the line when the site has no remote images.
responsiveStyles adds the CSS that makes images scale. Without it, images with a layout can overflow their container.
Choose the image location
Section titled “Choose the image location”- Put content images in
src/assets/, or next to the collection entry. Astro optimizes, resizes, and hashes them. - Put a file in
public/only when it needs a fixed URL. Examples are favicons and a default OG image that other systems link to. Astro never optimizes files inpublic/.
Render images through primitives
Section titled “Render images through primitives”Wrap <Image> and <Picture> from astro:assets in primitives, for example Photo.astro. Make alt and sizes required props in the primitive. The type check then enforces the rules below.
- Use
<Image>by default. It outputs WebP. - Use
<Picture formats={['avif', 'webp']}>for large photographs. List AVIF first, because the browser takes the first format that it supports. - Give every image
alt. Usealt=""for a decorative image. A missingaltfails the build. - Put
priorityon the one image that is the Largest Contentful Paint (LCP) element of the page. Don’t put it on other images. - Pass
sizesfor an image that is narrower than the viewport, for examplesizes="(min-width: 1024px) 33vw, 100vw"in a grid. The automatic value assumes a full-width image on small screens. - Always pass
sizesto<Picture>. Without it, the<source>elements get nosizes, and browsers download files about twice too large. - Use the quality presets
low,mid,high, andmax, not numbers. A number means different things for different formats. - Supply originals at least twice the largest display width. Astro never makes an image larger than its original.
Handle remote, SVG, and generated images
Section titled “Handle remote, SVG, and generated images”- Authorize each remote host in
image.remotePatterns. Astro doesn’t optimize an image from an unauthorized host. - Store the width and height of remote images in the CMS, and pass them as props.
inferSizedownloads each file at build time. - Import SVG icons and logos as components, for example
import Logo from '../assets/logo.svg'. Wrap them in theIconprimitive. - Never set
image.dangerouslyProcessSVGfor SVG files from untrusted sources. - Call
getImage()in the component script for CSS backgrounds and OG images. It throws in client code.
Control the build time
Section titled “Control the build time”- Each responsive image produces one file for each width and each format. Limit
image.breakpointson sites with many images. - AVIF encoding at the default effort is several times slower than WebP.
avif.effort: 2removes most of that cost. - Keep the Astro cache between CI builds.
npm cideletesnode_modules/, which holds the default cache. SetcacheDir: './.astro-cache'and cache that directory.
Source: images, astro:assets, image options, cacheDir, web.dev: optimize LCP.
8. Add fonts through the Fonts API
Section titled “8. Add fonts through the Fonts API”Load every web font through the fonts config. Astro downloads the files, serves them from /_astro/fonts/, and generates fallback fonts with matched metrics.
import { defineConfig, fontProviders } from 'astro/config';
export default defineConfig({ fonts: [{ provider: fontProviders.fontsource(), name: 'Inter', cssVariable: '--font-body', weights: [400, 700], styles: ['normal'], subsets: ['latin'], }],});Render the font in BaseHead, and use the CSS variable in global.css:
<Font cssVariable="--font-body" preload={[{ weight: '400', style: 'normal', subset: 'latin' }]} />- Set
weightsandstylesexplicitly. The defaults load weight 400 only, and they download italic files. - Preload only the faces that render above the fold. A
preloadwith no filter adds a preload link for every file. - Don’t add Google Fonts
<link>tags or hand-written@font-facerules. - With Tailwind 4, map the variable in the theme:
@theme inline { --font-sans: var(--font-body); }.
Source: fonts, font configuration.
9. Build links and navigation
Section titled “9. Build links and navigation”Send every link through the Link primitive. The primitive applies the URL rules in one place.
Link.astro does these things:
- Adds the trailing slash to an internal path that has no file extension.
- Sets
aria-current="page"when the path matchesAstro.url.pathname. - Keeps external links in the same tab by default. A
newTabprop setstarget="_blank". - Passes
data-astro-prefetchthrough for internal links.
External links
Section titled “External links”- Don’t add
rel="noopener"by hand. Current browsers applynoopenerto everytarget="_blank"link. - Add
rel="noreferrer"only on purpose. It removes the referrer, so partner sites lose attribution. - Markdown links bypass the
Linkprimitive. In MDX, mapatoLinkin thecomponentsprop. - In
.mdfiles, write a SätterihastPluginsentry for external links.rehype-external-linksneeds the unified processor, and it addsrel="nofollow"by default.
Prefetch and page transitions
Section titled “Prefetch and page transitions”Turn on prefetch, and opt in the main navigation links with data-astro-prefetch:
prefetch: { prefetchAll: false, defaultStrategy: 'hover' },Astro prefetches same-origin links only.
Use native cross-document view transitions for page animations. Add @view-transition { navigation: auto; } to global.css. They add no JavaScript. Browsers without support navigate normally.
Don’t use <ClientRouter /> on a content site:
- Astro CSP doesn’t support it.
- Bundled scripts don’t run again after a client-side navigation.
- It turns on
prefetchAll, so it prefetches every link.
Error pages and redirects
Section titled “Error pages and redirects”- Build
src/pages/404.astrowithBaseLayout. Astro emits404.html, and step 13 makes the host serve it. - Put permanent redirects in
public/_redirects, for example/old-page/ /new-page/ 301. Without an adapter, the Astroredirectsconfig makes<meta http-equiv="refresh">pages with no status code. - Write each redirect destination in the canonical form. Test each source with and without the trailing slash.
- Never write a rule that adds or removes a trailing slash. The host already does that, and the two rules loop.
Check the links
Section titled “Check the links”Check internal links against dist/ in CI with lychee in offline mode. Check external links on a schedule, not on each pull request, because external sites fail and rate-limit at random.
Source: prefetch, view transitions, external links recipe, routing, redirects, MDN: noopener, MDN: MPA view transitions, lychee.
10. Add the sitemap and robots.txt
Section titled “10. Add the sitemap and robots.txt”@astrojs/sitemap writes sitemap-index.xml and sitemap-0.xml at build time. It lists every built page.
- Without
site, the integration logs a warning, and the build succeeds with no sitemap. Add a CI check fordist/sitemap-index.xml. - Filter out pages that must not be indexed. Keep one list of these paths. Use it in the sitemap
filterand in thenoindexprop. - Emit
lastmodonly when the date is accurate. Set it for each entry withserialize(). - Don’t emit
changefreqorpriority. Google ignores both.
import { noindexPaths } from './src/data/noindex.ts';
sitemap({ filter: (page) => !noindexPaths.includes(new URL(page).pathname) }),Generate robots.txt from site with an endpoint:
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ site }) => { const sitemapURL = new URL('sitemap-index.xml', site); return new Response(`User-agent: *\nAllow: /\nSitemap: ${sitemapURL.href}\n`);};With base, the sitemap lives under the base path, and this endpoint points to the wrong URL. Build the URL from BASE_URL, or submit the sitemap URL in Google Search Console only.
Submit the sitemap in Google Search Console after the first release.
Source: sitemap integration, Google: build a sitemap.
11. Add interactivity
Section titled “11. Add interactivity”Choose the lightest option that meets the requirement, in this order:
- Static HTML from an Astro component, for example
<details>for a disclosure. - A
<script>in the component. Pass server data throughdata-*attributes. Bind the behavior for each instance with a custom element. - A UI framework island with a
client:*directive, on the smallest component that holds the state.
Follow these rules for scripts:
- Prefer a plain
<script>. Astro bundles it, compiles TypeScript, and includes it once per page. - Use
<script is:inline>only for code that must run where you write it. Astro doesn’t bundle it, doesn’t deduplicate it, and doesn’t hash it for CSP.
Follow these rules for islands:
- Use
client:visiblebelow the fold andclient:idlefor low-priority work. Useclient:loadonly when the island must work at once. - Pass only serializable props: strings, numbers, plain objects, arrays, and dates. A function prop arrives as
null, with no warning. - Pass IDs, not whole collections. Astro serializes every prop into the HTML of each page.
- Always name the framework in
client:only, for exampleclient:only="preact". The build fails when a second framework is added. - Never put a
client:*directive on an Astro component. The build succeeds, and the component stays static. - Share state between islands with Nano Stores, not with framework context. Each island hydrates separately.
- Pass Astro content to a framework component through slots. Never import an
.astrofile into a framework component.
Source: client-side scripts, framework components, client directives, sharing state between islands.
12. Set the HTTP headers and the CSP
Section titled “12. Set the HTTP headers and the CSP”Write the host headers
Section titled “Write the host headers”Put the response headers in public/_headers. Astro copies the file to dist/, and Cloudflare and Netlify apply it.
/* X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Cross-Origin-Opener-Policy: same-origin X-Frame-Options: DENY Content-Security-Policy: frame-ancestors 'none'; base-uri 'none'; object-src 'none'; form-action 'self'
/_astro/* Cache-Control: public, max-age=31536000, immutableFollow these rules:
- Cache
/_astro/*for one year asimmutable. Astro puts only hashed files there: scripts, styles, images, and fonts. - Never set
Cache-Controlon/*. The host joins duplicate values into one conflicting header. HTML andpublic/files keep the host default, which revalidates on each request. - Keep the header CSP to
frame-ancestors,base-uri,object-src, andform-action. Browsers apply every policy that they receive. A headerscript-srchas no Astro hashes, so it blocks the inline scripts of Astro. - Add each third-party form endpoint to
form-action. - Don’t send
X-XSS-Protection. It can create XSS vulnerabilities. - Don’t send
Cross-Origin-Embedder-Policy: require-corpon a marketing site. It blocks video and map embeds. - On Vercel, set the headers in
vercel.json. The Netlify and Vercel adapters add the/_astro/*cache rule automatically.
Turn on HSTS at the Cloudflare zone, not in _headers. Increase max-age in stages up to one year. Add includeSubDomains only when every subdomain serves HTTPS. Add preload only with approval from the domain owner, because removal takes months.
Turn on Astro CSP
Section titled “Turn on Astro CSP”Astro hashes its own scripts and styles and writes a Content Security Policy.
export default defineConfig({ security: { csp: { directives: ["default-src 'self'", "img-src 'self' data:", "connect-src 'self'"], }, },});Follow these rules:
- Without an adapter that supports
staticHeaders, Astro writes the policy as a<meta>tag. Browsers ignoreframe-ancestors,report-uri, andsandboxin a meta tag. Sendframe-ancestorsfrom_headers. - On Netlify and Vercel, set
staticHeaders: trueon the adapter. Astro then sends the policy as a header. - List every third-party script before you turn on CSP. Add each host to
scriptDirective.resources. The list replaces the default, so include'self'. - Convert each
<script is:inline>to a processed<script>. When you can’t, add its hash toscriptDirective.hashes. - Never add
'unsafe-inline'to the script resources. Astro then emits no hashes for that directive. - Allow the inline style attributes of Shiki with
styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: 'attribute' }] }. As an alternative, use Prism. - Turn off Cloudflare Rocket Loader and Scrape Shield. They change the page after the build, and the policy blocks the change.
- Don’t run a post-build HTML minifier. It changes the inline code, and the hashes stop matching.
- Test the CSP on a production build. CSP doesn’t work in
astro dev.
JSON-LD needs no hash. Browsers never execute application/ld+json scripts.
Know where middleware runs
Section titled “Know where middleware runs”Middleware runs at build time for prerendered pages. Headers that middleware sets on these pages never reach the browser. Set the headers for prerendered pages in _headers or in the host config. Use src/middleware.ts for the headers of on-demand pages only.
Source: security.csp, CSP runtime API, middleware, Cloudflare: headers, Cloudflare: HSTS, OWASP HTTP headers cheat sheet, CSP: meta element limits, MDN: multiple policies, hstspreload.org.
13. Configure the host and CI
Section titled “13. Configure the host and CI”For a fully static site on Cloudflare, don’t install @astrojs/cloudflare. Commit an explicit Wrangler config:
name = "SITE-NAME"compatibility_date = "DEPLOY-DATE"
[assets]directory = "./dist"not_found_handling = "404-page"html_handling = "auto-trailing-slash"Replace the following:
SITE-NAME: the name of the Worker.DEPLOY-DATE: the date of the first deploy, asYYYY-MM-DD.
Follow these rules:
- Set
not_found_handling = "404-page". Cloudflare then servesdist/404.html. The default returns a 404 with an empty body. - Keep
html_handling = "auto-trailing-slash". It redirects/aboutto/about/, which matches the URL format from step 2. - Don’t rely on Wrangler auto-configuration. It adds an adapter and an entry point that a static site doesn’t need.
- Test with
npm run build && npx wrangler devbefore you deploy.astro devdoesn’t apply_headersor_redirects.
When a page must render on demand, add @astrojs/cloudflare, and follow these rules:
- Set
imageService: 'compile'. The default transforms images at request time, and Cloudflare charges for each transformation. - Check
dist/server/wrangler.jsonafter the build. The adapter moves the static files todist/client/. _headersdoesn’t apply to responses from the Worker. Set the headers of on-demand pages in middleware.
On Vercel, install the adapter or set trailingSlash in vercel.json. Without either, Vercel serves each page at two URLs.
Run this sequence in CI:
- Install with
npm ci. Keep optional dependencies, because the compiler andsharpship as optional native packages. - Restore the Astro cache directory.
- Run
npm run build. Fail the job when the build log contains[ERROR]. Astro logs some content errors, such as a brokenreference(), and still exits with code 0. - Run the
dist/checks from the verification section. - Deploy
dist/.
Source: deploy to Cloudflare, Cloudflare adapter, Cloudflare: HTML handling, Cloudflare: redirects, Vercel: trailingSlash.
Minimum viable version
Section titled “Minimum viable version”Ship these items first:
- The project, the site URL, and the URL format, from steps 1 and 2.
- The
ui/primitives,BaseLayout,BaseHead,Header, andFooter, from steps 3 to 5. - The pages, composed from section components, with images through
astro:assets. - The sitemap,
robots.txt,404.astro, and the baseline_headersfile.
Add these items when a real problem needs them:
- Astro CSP, after you list every third-party script.
- MDX, when a content type needs a component in its body.
<Picture>with AVIF, when image weight limits the LCP.- UI framework islands, when a
<script>can’t manage the state. - i18n routing and
hreflang, when a second language ships. - An RSS feed, when the site publishes dated content.
Change workflow
Section titled “Change workflow”Use this sequence to add a page:
- Name the page type, and select its layout. If no layout fits and more pages of this type will follow, add a page-type layout that wraps
BaseLayout. - List the sections of the page. Reuse the existing section components.
- For a missing section, build a section component from primitives. For a missing primitive, add it to
ui/first. - Put repeating content in a collection entry, not in the page file.
- Pass the title, the description, and the OG image to the layout.
- Add images to
src/assets/, and render them through the image primitives. - Link to the page with its canonical URL, through
Link. Add it tosrc/data/site.tsif it belongs in the navigation. - Run
npm run buildand thedist/checks. Review the page withnpx wrangler dev.
Design rules
Section titled “Design rules”- Components are the unit of work. Each piece of styled or repeated markup lives in exactly one component. A fix to a component fixes every page that uses it.
- Ship HTML, not JavaScript. Add client code only when HTML and CSS can’t meet the requirement. Each island adds a download and a hydration cost.
- Keep one source of truth. The site URL lives in
site. The navigation lives insrc/data/site.ts. One noindex list feeds both the head and the sitemap. - Type every boundary. Type the component props, the collection schemas, and the environment variables.
astro checkthen finds the error before a visitor does. - Test the build output, not the dev server. CSP,
_headers,_redirects, trailing slashes, and whitespace behave differently inastro dev. - Trust the reference over tutorials. Most tutorials and model answers describe Astro 5 or earlier. When sources disagree, the configuration reference and the changelog win.
Reject advice that uses these older APIs:
| Older API | Astro 7 replacement |
|---|---|
src/content/config.ts and type: 'content' | src/content.config.ts with a loader |
entry.render() and entry.slug | render(entry) and entry.id |
Astro.glob() | import.meta.glob() or getCollection() |
import { z } from 'astro:content' | import { z } from 'astro/zod' |
<ViewTransitions /> | Native view transitions, or <ClientRouter /> |
@astrojs/tailwind | @tailwindcss/vite, through npx astro add tailwind |
experimental.fonts and experimental.csp | fonts and security.csp |
Top-level markdown.remarkPlugins | markdown.processor: unified({ remarkPlugins }) |
Anti-patterns
Section titled “Anti-patterns”| Anti-pattern | Failure mode | Accepted pattern |
|---|---|---|
| Styled markup in a page file | The element drifts into several versions, and a design change needs edits in many files | Extract a component, and compose the page from components |
| Copy a block of markup to a second page | The copies diverge after the first edit | Make the block a section component on its second use |
A primitive that ignores class | Callers can’t adjust spacing, so they add wrapper elements | Destructure class and merge it with class:list |
Spread ...rest and set class on one element | Astro emits two class attributes, and the browser ignores one | Destructure class, and merge it |
A child root element without ...rest | The scoped styles of the parent don’t reach the child | Spread ...rest onto the root element |
| Boolean mode flags on a component | The flags combine into states that nobody designed | Use string-union props, and split at more than three modes |
slot on a nested element | The content goes to the default slot | Put slot on an immediate child, or use <Fragment slot> |
| A conditional element as a named slot | Astro.slots.has() returns true, and an empty wrapper renders | Put the condition around the whole component |
A page that renders <html> or <head> | Metadata and global CSS differ from page to page | Render the shell only in BaseLayout |
<meta> tags in page files | Pages miss the canonical link or the OG tags | Pass props to the layout, and let BaseHead render the tags |
No site in the config | The build succeeds with no sitemap and with localhost URLs | Set site, and check for dist/sitemap-index.xml in CI |
The default trailingSlash: 'ignore' | One page answers at two URLs with two canonical links | Set trailingSlash: 'always' with build.format: 'directory' |
build.format: 'file' with the canonical recipe | The canonical URL ends in .html, and the sitemap disagrees | Use the directory format |
changefreq and priority in the sitemap | Google ignores them, so they only add noise | Emit an accurate lastmod only |
A preview blocked in robots.txt | Crawlers never see noindex, so the URLs can stay indexed | Send noindex as a header or a meta tag |
JSON-LD with an unescaped set:html | A CMS string with </script> injects script | Escape < in the JsonLd primitive |
Images in public/ | Images ship at full size, with no srcset | Import images from src/assets/ |
A responsive layout without responsiveStyles | Images overflow or distort | Set image.responsiveStyles: true |
<Picture> without sizes | Browsers download images about twice too large | Make sizes a required prop of the image primitive |
priority on many images | The browser can’t tell which image matters, and the LCP gets worse | Put priority on the LCP image only |
| The hero image in the Markdown body | Markdown images always load lazily, so the LCP gets worse | Put the hero in the frontmatter, and render it with priority |
formats={['webp', 'avif']} | The browser takes WebP, so it never uses AVIF | List AVIF first |
| The Cloudflare adapter with the default image service | Images transform at request time, with a charge for each transformation | Set imageService: 'compile' |
The default cacheDir with npm ci | Every CI build encodes every image again | Set cacheDir outside node_modules/, and cache it |
<Font preload> with no filter | The page preloads every font file | Preload only the faces above the fold |
Google Fonts <link> tags | A third-party request blocks rendering, and the CSP needs another host | Use the Fonts API |
.mdx for prose from non-developers | A stray < or { fails the build | Use .md, or Markdoc through a CMS |
import statements in each MDX file | Every file that imports a component fails when the component moves | Pass components through the components prop |
| Unsanitized Markdown from untrusted authors | A raw <script> reaches the built HTML | Sanitize the Markdown, or use Markdoc with HTML off |
Landing page copy written in the .astro file | Writers need a developer for each copy change | Put the copy in a data collection, and map it to section components |
| remark or rehype plugins with the default processor | The plugin output disappears from the HTML | Use unified(), or port the plugin to Sätteri |
rehype-external-links with its default options | Every external link gets nofollow | Pass an explicit rel |
rel="noopener noreferrer" on every external link | Partner sites lose referral data | Rely on the implied noopener, and add noreferrer only on purpose |
Astro redirects on a static site with no adapter | Each redirect is a meta refresh page with no 301 status | Put redirects in public/_redirects |
| A redirect rule that adds or removes a trailing slash | The rule and the host normalization loop | Let the host normalize trailing slashes |
<ClientRouter /> on a content site | CSP doesn’t support it, and bundled scripts don’t run again after navigation | Use native cross-document view transitions |
| A function prop on a hydrated island | The client receives null, with no warning | Define the handler inside the island |
client:load on an Astro component | The build succeeds, and nothing hydrates | Use a framework component or a <script> |
| A whole collection passed to an island | Every page carries the serialized data in its HTML | Pass IDs, and load the data inside the island |
Inline elements on separate lines with no {" "} | Words join, with no build error | Write {" "} between inline elements |
| Unclosed tags from HTML habits | The Astro 7 compiler fails the build | Close every element that isn’t a void element |
Cache-Control on /* | HTML caches for a year, or /_astro/* gets conflicting values | Set Cache-Control on /_astro/* only |
script-src in the header CSP | The header policy blocks the hashed inline scripts of Astro | Keep script and style rules in Astro CSP |
frame-ancestors in Astro CSP without header delivery | The meta tag ignores it, so clickjacking protection fails with no warning | Send frame-ancestors from _headers |
<script is:inline> under CSP | The browser blocks the script, with no build warning | Use a processed <script>, or add its hash |
'unsafe-inline' in the script resources | Astro drops all hashes from the directive | Hash the snippet instead |
| Security headers from middleware on static pages | Middleware runs at build time, so the headers never ship | Set the headers in _headers |
X-XSS-Protection: 1; mode=block | The header can create XSS vulnerabilities | Don’t send the header |
HSTS preload on the first deploy | A subdomain without HTTPS becomes unreachable for months | Increase max-age in stages, and preload only with owner approval |
No not_found_handling in the Wrangler config | Unknown paths return a 404 with an empty body | Set not_found_handling = "404-page" |
npm install -D typescript with no version | TypeScript 7 installs, and astro check fails | Install typescript@^6 |
| Tailwind through PostCSS | The production build fails | Use @tailwindcss/vite |
Tests in astro dev only | CSP, headers, redirects, and slash handling fail in production | Test npm run build with npx wrangler dev |
Verification
Section titled “Verification”The pass signal is a build with exit code 0 and no [ERROR] line in its log. The build script runs astro check first.
npm run build > build.log 2>&1; echo "exit: $?"grep -F '[ERROR]' build.logThe expected results:
- The first command prints
exit: 0. - The
grepcommand prints nothing.
Then run these checks against dist/:
test -f dist/sitemap-index.xml && test -f dist/robots.txt && test -f dist/404.htmlgrep -rL 'rel="canonical"' dist --include='*.html'grep -rl 'localhost' dist --include='*.html'lychee --offline --root-dir "$PWD/dist" --index-files index.html --include-fragments 'dist/**/*.html'The expected results:
- The
testcommand exits with code 0. - Each
grepcommand prints nothing. lycheereports no errors.
Then check the deployed site:
curl -sI https://SITE/ | grep -iE 'x-content-type-options|referrer-policy|content-security-policy|x-frame-options'curl -sI https://SITE/_astro/FILE | grep -i cache-controlcurl -sI https://SITE/about | grep -iE '^(http|location)'curl -sI https://SITE/does-not-exist/ | head -1Replace the following:
SITE: the production host name.FILE: the name of any file indist/_astro/.
The expected results:
- The first command prints the four header names.
- The second command prints
public, max-age=31536000, immutable. - The third command prints a 307 status and a
locationof/about/. - The fourth command prints a 404 status.
Then run these external checks:
- The Google Rich Results Test on one article.
- The Mozilla HTTP Observatory on the home page.
- PageSpeed Insights on the home page and on one article.
Review checklist
Section titled “Review checklist”Use this checklist for each pull request.
Components
Section titled “Components”- The page files contain no
<style>block and no styled markup. - Markup that appears twice is a component.
- Each primitive has
interface Props, acceptsclass, and spreads...restonto its root. - No element spreads
...restand also sets the same attribute. - Modes use string-union props, and no component has more than three mode props.
- Each named slot is on an immediate child.
- Inline elements on separate lines have
{" "}between them.
Layouts and metadata
Section titled “Layouts and metadata”- The page uses a page-type layout that wraps
BaseLayout. - The page passes a unique title and description.
- Absolute URLs come from
Astro.site. - JSON-LD goes through the
JsonLdprimitive.
Content
Section titled “Content”- New repeating content is in a collection with a schema.
- The file format follows the format table in step 6.
- List pages sort entries and filter drafts through the shared helper.
Images and fonts
Section titled “Images and fonts”- Content images come from
src/, not frompublic/. - Each image has
alt, and decorative images usealt="". - Only the LCP image has
priority. - Grid images and every
<Picture>passsizes. - Fonts load through the Fonts API.
- Internal links go through
Linkand end with a slash. - New redirects are in
public/_redirectswith a 301 status. -
lycheepasses ondist/.
Interactivity and security
Section titled “Interactivity and security”- New client code uses the lightest option from step 11.
- Island props are IDs or small serializable values.
- Each new
<script is:inline>has a CSP hash. - Each new third-party host is in the CSP.
Release checklist
Section titled “Release checklist”-
siteis the production origin. -
dist/sitemap-index.xmllists only pages that must be indexed. -
robots.txtpoints to the production sitemap. - The production HTML has no
noindexmeta tag. - The 404 page returns status 404.
- Each redirect returns 301 to a canonical URL.
- The response headers match
public/_headers. - Files in
/_astro/return the immutable cache header. - The browser console shows no CSP violations on each page type.
- The Google Rich Results Test passes for each JSON-LD type.
- PageSpeed Insights reports a good LCP and CLS for the home page and one article.
- The sitemap is submitted in Google Search Console.
Sources
Section titled “Sources”- Astro 7 release
- Upgrade to Astro 7
- Upgrade to Astro 6
- Configuration reference
- Astro changelog
- Astro components
- Layouts
- Styling
- TypeScript
- Content collections
- Markdown
- MDX integration
- Images
- Fonts
- Routing
- Prefetch
- View transitions
- Sitemap integration
- Client-side scripts
- Framework components
- Middleware
- Deploy to Cloudflare
- Official blog example
- Cloudflare Workers static assets: headers
- Cloudflare Workers static assets: HTML handling
- OWASP HTTP headers cheat sheet
- Google Search Central: canonical URLs
- Google Search Central: sitemaps
- web.dev: optimize LCP