Skip to content

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.

An Astro site has four layers. Each layer has one job and one invariant.

LayerLocationJobInvariant
Pagessrc/pages/Map a URL to a layout and componentsHolds no <style> block and no styled markup
Layoutssrc/layouts/Own the document shell and the frame for one page typeOnly BaseLayout renders <html>, <head>, and <body>
Componentssrc/components/Own every piece of repeated or styled markupTyped props, one root element, accepts class
Contentsrc/content/ and src/content.config.tsOwn prose and structured dataEvery 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 in public/ 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.

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.
  • 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/strict preset.
  • @astrojs/check and typescript@^6 as dev dependencies. Pin TypeScript 6, because astro check does 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 with npx 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, and src/content.config.ts.
  • public/_headers and public/_redirects.
  • The Wrangler config file.

Never commit these items:

  • .env files, CMS tokens, and API keys.
  • dist/ and .astro/.
  • node_modules/ and the Astro cache directory.
PartPurpose
astro.config.mjsThe site URL, the URL format, images, fonts, Markdown, CSP, and integrations
src/content.config.tsThe loader and the schema for each collection
src/data/site.tsSite-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.astroThe document shell
src/layouts/*Layout.astroOne 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/_redirectsResponse 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.ts
public/ # _headers, _redirects, favicons

src/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.

Scaffold with the official CLI. Add integrations with astro add.

Terminal window
npm create astro@latest SITE-NAME
cd SITE-NAME
npx astro add sitemap
npm install -D @astrojs/check typescript@^6

Replace 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.

Set these keys before you write a link. Every URL in the site depends on them.

astro.config.mjs
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' with build.format: 'directory': each page builds to about/index.html and lives at /about/. Static hosts serve that form by default. The canonical link, the sitemap, and paginate() 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.

Build the primitives before the first page. Then build every page from them. This step carries the one rule of the playbook.

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.

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.

src/components/ui/Section.astro
---
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 class and merge it into class:list. class does not pass through to a child component automatically.
  • Spread ...rest onto the root element. The spread carries the data-astro-cid-* attribute, so the scoped styles of the parent reach the child.
  • Never spread ...rest and also set the same attribute. Astro then emits two class attributes, and the browser ignores the second one.
  • Read only class in a component. A class:list from the caller arrives as class.
  • Reuse the props of a wrapped component with ComponentProps<typeof Component> from astro/types.
  • Use a polymorphic as prop only to change the element, for example the level of a Heading. For a link or a button, branch on href.

Source: component props, passing a class to a child component, class:list.

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. A slot attribute 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 example actions.

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.

  • 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, in BaseLayout.
  • Style slotted content or rendered Markdown with :global() inside a wrapper, for example .prose :global(h2). Keep these rules in the Prose primitive 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.

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 onclick or other event attributes on elements. Astro converts attribute values to strings. Use a <script> instead.

Source: upgrade to Astro 7, compressHTML.

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.

Build one base layout. Then build one layout for each page type. Each page-type layout wraps 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 BaseHead with the metadata props of the page.
  • Exposes a named head slot for page additions, such as JSON-LD.
  • Renders Header, the default slot inside <main>, and Footer.
src/layouts/BaseLayout.astro
---
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.

LayoutPagesFrame
LandingLayoutHome, product, and campaign pagesFull-width sections, no prose column
ArticleLayoutBlog posts, case studies, and newsProse column, title, date, author, table of contents
LegalLayoutPrivacy policy and termsProse 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 BaseLayout and passes the head props through.
  • It forwards the head slot 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.

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.

src/components/site/BaseHead.astro
---
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:

  1. <meta charset="utf-8">. It must be in the first 1024 bytes of the document.
  2. <meta name="viewport" content="width=device-width, initial-scale=1">.
  3. <title> and <meta name="description">.
  4. <link rel="canonical" href={canonicalURL}>.
  5. <meta name="robots" content="noindex">, when noindex is true.
  6. Open Graph: og:type, og:url with the canonical URL, og:title, og:description, og:image, og:image:alt, and og:site_name.
  7. <meta name="twitter:card" content="summary_large_image">. X reads the Open Graph tags for the other values.
  8. The favicons, the sitemap link, and the RSS link.
  9. 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. Without site, Astro.url gives localhost URLs 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.ico at 32 × 32, icon.svg, and apple-touch-icon.png at 180 × 180 in public/. Google Search doesn’t show SVG favicons.
  • Don’t add <meta name="keywords">. Google ignores it.
  • Rely on the host first. Most hosts send X-Robots-Tag: noindex on preview URLs, but not on every branch URL.
  • Set noindex in BaseHead for every other non-production build with a public URL. Gate it on a build-time variable from astro:env.

Never block a preview in robots.txt. A crawler that can’t fetch a page never sees its noindex rule.

Render JSON-LD with a JsonLd primitive. The primitive escapes <, because set:html doesn’t escape its value.

src/components/ui/JsonLd.astro
---
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.

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.

src/content.config.ts
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 z from astro/zod. Schemas use Zod 4 syntax, for example z.url() and z.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 a reference(). A broken reference logs an error, but the build continues.
  • Route entries with [...id].astro and string params from getStaticPaths(). Render an entry with render(entry), and pass headings to the layout.

Default to .md in a collection. Choose another format only for a reason in this table.

FormatChoose it whenNever choose it whenWho edits it
.md in a collectionThe content is prose with frontmatter: posts, case studies, changelogs, and legal textThe body needs a component, or an element must render as a componentAnyone: writers, a Git-based CMS, and developers
.mdx in a collectionThe body needs a small, fixed set of components, or a and h2 must render as componentsThe authors are untrusted, or the content has no componentsDevelopers and trusted writers
.mdoc (Markdoc) in a collectionNon-developers need custom components with checked attributes, and raw HTML must stay offBuild time matters on a large collectionNon-developers, through a CMS
.json or .yaml in a collectionThe content is structured records: team members, testimonials, and the copy blocks of a landing pageThe content is long proseAnyone, through a CMS or Git
.astro pageThe page is a designed composition of sections: home, landing, pricing, and campaign pages. Also every route that renders a collectionThe page is long prose, or non-developers must change its copyDevelopers only
.md in src/pages/One simple page with no siblings. Set layout to a page-type layoutThe site has several pages of the same shapeDevelopers

The reasons:

  • .md is the cheapest and safest format. It builds about twice as fast as .mdx, and it runs no code at build time.
  • An .mdx file 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 .md or .mdx file in src/pages/ skips schema validation. Types from MarkdownLayoutProps don’t check values at runtime.
  • Prose inside .astro files 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 the components mapping for every element that isn’t in ignoreElementNames.

.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.

  • Wrap rendered Markdown in the Prose primitive. Prose owns 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.
  • .md ignores the components prop. Change element attributes in .md with a Sätteri hastPlugins entry.
  • Keep the hero image out of the Markdown body. Put it in the frontmatter, and render it in the layout with priority.

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.

Turn on responsive images for the whole site. They are off by default.

astro.config.mjs
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.

  • 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 in public/.

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. Use alt="" for a decorative image. A missing alt fails the build.
  • Put priority on the one image that is the Largest Contentful Paint (LCP) element of the page. Don’t put it on other images.
  • Pass sizes for an image that is narrower than the viewport, for example sizes="(min-width: 1024px) 33vw, 100vw" in a grid. The automatic value assumes a full-width image on small screens.
  • Always pass sizes to <Picture>. Without it, the <source> elements get no sizes, and browsers download files about twice too large.
  • Use the quality presets low, mid, high, and max, 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.
  • 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. inferSize downloads each file at build time.
  • Import SVG icons and logos as components, for example import Logo from '../assets/logo.svg'. Wrap them in the Icon primitive.
  • Never set image.dangerouslyProcessSVG for SVG files from untrusted sources.
  • Call getImage() in the component script for CSS backgrounds and OG images. It throws in client code.
  • Each responsive image produces one file for each width and each format. Limit image.breakpoints on sites with many images.
  • AVIF encoding at the default effort is several times slower than WebP. avif.effort: 2 removes most of that cost.
  • Keep the Astro cache between CI builds. npm ci deletes node_modules/, which holds the default cache. Set cacheDir: './.astro-cache' and cache that directory.

Source: images, astro:assets, image options, cacheDir, web.dev: optimize LCP.

Load every web font through the fonts config. Astro downloads the files, serves them from /_astro/fonts/, and generates fallback fonts with matched metrics.

astro.config.mjs
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 weights and styles explicitly. The defaults load weight 400 only, and they download italic files.
  • Preload only the faces that render above the fold. A preload with no filter adds a preload link for every file.
  • Don’t add Google Fonts <link> tags or hand-written @font-face rules.
  • With Tailwind 4, map the variable in the theme: @theme inline { --font-sans: var(--font-body); }.

Source: fonts, font configuration.

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 matches Astro.url.pathname.
  • Keeps external links in the same tab by default. A newTab prop sets target="_blank".
  • Passes data-astro-prefetch through for internal links.
  • Don’t add rel="noopener" by hand. Current browsers apply noopener to every target="_blank" link.
  • Add rel="noreferrer" only on purpose. It removes the referrer, so partner sites lose attribution.
  • Markdown links bypass the Link primitive. In MDX, map a to Link in the components prop.
  • In .md files, write a Sätteri hastPlugins entry for external links. rehype-external-links needs the unified processor, and it adds rel="nofollow" by default.

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.
  • Build src/pages/404.astro with BaseLayout. Astro emits 404.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 Astro redirects config 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 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.

@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 for dist/sitemap-index.xml.
  • Filter out pages that must not be indexed. Keep one list of these paths. Use it in the sitemap filter and in the noindex prop.
  • Emit lastmod only when the date is accurate. Set it for each entry with serialize().
  • Don’t emit changefreq or priority. Google ignores both.
astro.config.mjs
import { noindexPaths } from './src/data/noindex.ts';
sitemap({ filter: (page) => !noindexPaths.includes(new URL(page).pathname) }),

Generate robots.txt from site with an endpoint:

src/pages/robots.txt.ts
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.

Choose the lightest option that meets the requirement, in this order:

  1. Static HTML from an Astro component, for example <details> for a disclosure.
  2. A <script> in the component. Pass server data through data-* attributes. Bind the behavior for each instance with a custom element.
  3. 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:visible below the fold and client:idle for low-priority work. Use client:load only 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 example client: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 .astro file into a framework component.

Source: client-side scripts, framework components, client directives, sharing state between islands.

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, immutable

Follow these rules:

  • Cache /_astro/* for one year as immutable. Astro puts only hashed files there: scripts, styles, images, and fonts.
  • Never set Cache-Control on /*. The host joins duplicate values into one conflicting header. HTML and public/ files keep the host default, which revalidates on each request.
  • Keep the header CSP to frame-ancestors, base-uri, object-src, and form-action. Browsers apply every policy that they receive. A header script-src has 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-corp on 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.

Astro hashes its own scripts and styles and writes a Content Security Policy.

astro.config.mjs
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 ignore frame-ancestors, report-uri, and sandbox in a meta tag. Send frame-ancestors from _headers.
  • On Netlify and Vercel, set staticHeaders: true on 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 to scriptDirective.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.

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.

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, as YYYY-MM-DD.

Follow these rules:

  • Set not_found_handling = "404-page". Cloudflare then serves dist/404.html. The default returns a 404 with an empty body.
  • Keep html_handling = "auto-trailing-slash". It redirects /about to /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 dev before you deploy. astro dev doesn’t apply _headers or _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.json after the build. The adapter moves the static files to dist/client/.
  • _headers doesn’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:

  1. Install with npm ci. Keep optional dependencies, because the compiler and sharp ship as optional native packages.
  2. Restore the Astro cache directory.
  3. Run npm run build. Fail the job when the build log contains [ERROR]. Astro logs some content errors, such as a broken reference(), and still exits with code 0.
  4. Run the dist/ checks from the verification section.
  5. Deploy dist/.

Source: deploy to Cloudflare, Cloudflare adapter, Cloudflare: HTML handling, Cloudflare: redirects, Vercel: trailingSlash.

Ship these items first:

  1. The project, the site URL, and the URL format, from steps 1 and 2.
  2. The ui/ primitives, BaseLayout, BaseHead, Header, and Footer, from steps 3 to 5.
  3. The pages, composed from section components, with images through astro:assets.
  4. The sitemap, robots.txt, 404.astro, and the baseline _headers file.

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.

Use this sequence to add a page:

  1. 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.
  2. List the sections of the page. Reuse the existing section components.
  3. For a missing section, build a section component from primitives. For a missing primitive, add it to ui/ first.
  4. Put repeating content in a collection entry, not in the page file.
  5. Pass the title, the description, and the OG image to the layout.
  6. Add images to src/assets/, and render them through the image primitives.
  7. Link to the page with its canonical URL, through Link. Add it to src/data/site.ts if it belongs in the navigation.
  8. Run npm run build and the dist/ checks. Review the page with npx wrangler dev.
  • 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 in src/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 check then finds the error before a visitor does.
  • Test the build output, not the dev server. CSP, _headers, _redirects, trailing slashes, and whitespace behave differently in astro 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 APIAstro 7 replacement
src/content/config.ts and type: 'content'src/content.config.ts with a loader
entry.render() and entry.slugrender(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.cspfonts and security.csp
Top-level markdown.remarkPluginsmarkdown.processor: unified({ remarkPlugins })
Anti-patternFailure modeAccepted pattern
Styled markup in a page fileThe element drifts into several versions, and a design change needs edits in many filesExtract a component, and compose the page from components
Copy a block of markup to a second pageThe copies diverge after the first editMake the block a section component on its second use
A primitive that ignores classCallers can’t adjust spacing, so they add wrapper elementsDestructure class and merge it with class:list
Spread ...rest and set class on one elementAstro emits two class attributes, and the browser ignores oneDestructure class, and merge it
A child root element without ...restThe scoped styles of the parent don’t reach the childSpread ...rest onto the root element
Boolean mode flags on a componentThe flags combine into states that nobody designedUse string-union props, and split at more than three modes
slot on a nested elementThe content goes to the default slotPut slot on an immediate child, or use <Fragment slot>
A conditional element as a named slotAstro.slots.has() returns true, and an empty wrapper rendersPut the condition around the whole component
A page that renders <html> or <head>Metadata and global CSS differ from page to pageRender the shell only in BaseLayout
<meta> tags in page filesPages miss the canonical link or the OG tagsPass props to the layout, and let BaseHead render the tags
No site in the configThe build succeeds with no sitemap and with localhost URLsSet site, and check for dist/sitemap-index.xml in CI
The default trailingSlash: 'ignore'One page answers at two URLs with two canonical linksSet trailingSlash: 'always' with build.format: 'directory'
build.format: 'file' with the canonical recipeThe canonical URL ends in .html, and the sitemap disagreesUse the directory format
changefreq and priority in the sitemapGoogle ignores them, so they only add noiseEmit an accurate lastmod only
A preview blocked in robots.txtCrawlers never see noindex, so the URLs can stay indexedSend noindex as a header or a meta tag
JSON-LD with an unescaped set:htmlA CMS string with </script> injects scriptEscape < in the JsonLd primitive
Images in public/Images ship at full size, with no srcsetImport images from src/assets/
A responsive layout without responsiveStylesImages overflow or distortSet image.responsiveStyles: true
<Picture> without sizesBrowsers download images about twice too largeMake sizes a required prop of the image primitive
priority on many imagesThe browser can’t tell which image matters, and the LCP gets worsePut priority on the LCP image only
The hero image in the Markdown bodyMarkdown images always load lazily, so the LCP gets worsePut the hero in the frontmatter, and render it with priority
formats={['webp', 'avif']}The browser takes WebP, so it never uses AVIFList AVIF first
The Cloudflare adapter with the default image serviceImages transform at request time, with a charge for each transformationSet imageService: 'compile'
The default cacheDir with npm ciEvery CI build encodes every image againSet cacheDir outside node_modules/, and cache it
<Font preload> with no filterThe page preloads every font filePreload only the faces above the fold
Google Fonts <link> tagsA third-party request blocks rendering, and the CSP needs another hostUse the Fonts API
.mdx for prose from non-developersA stray < or { fails the buildUse .md, or Markdoc through a CMS
import statements in each MDX fileEvery file that imports a component fails when the component movesPass components through the components prop
Unsanitized Markdown from untrusted authorsA raw <script> reaches the built HTMLSanitize the Markdown, or use Markdoc with HTML off
Landing page copy written in the .astro fileWriters need a developer for each copy changePut the copy in a data collection, and map it to section components
remark or rehype plugins with the default processorThe plugin output disappears from the HTMLUse unified(), or port the plugin to Sätteri
rehype-external-links with its default optionsEvery external link gets nofollowPass an explicit rel
rel="noopener noreferrer" on every external linkPartner sites lose referral dataRely on the implied noopener, and add noreferrer only on purpose
Astro redirects on a static site with no adapterEach redirect is a meta refresh page with no 301 statusPut redirects in public/_redirects
A redirect rule that adds or removes a trailing slashThe rule and the host normalization loopLet the host normalize trailing slashes
<ClientRouter /> on a content siteCSP doesn’t support it, and bundled scripts don’t run again after navigationUse native cross-document view transitions
A function prop on a hydrated islandThe client receives null, with no warningDefine the handler inside the island
client:load on an Astro componentThe build succeeds, and nothing hydratesUse a framework component or a <script>
A whole collection passed to an islandEvery page carries the serialized data in its HTMLPass IDs, and load the data inside the island
Inline elements on separate lines with no {" "}Words join, with no build errorWrite {" "} between inline elements
Unclosed tags from HTML habitsThe Astro 7 compiler fails the buildClose every element that isn’t a void element
Cache-Control on /*HTML caches for a year, or /_astro/* gets conflicting valuesSet Cache-Control on /_astro/* only
script-src in the header CSPThe header policy blocks the hashed inline scripts of AstroKeep script and style rules in Astro CSP
frame-ancestors in Astro CSP without header deliveryThe meta tag ignores it, so clickjacking protection fails with no warningSend frame-ancestors from _headers
<script is:inline> under CSPThe browser blocks the script, with no build warningUse a processed <script>, or add its hash
'unsafe-inline' in the script resourcesAstro drops all hashes from the directiveHash the snippet instead
Security headers from middleware on static pagesMiddleware runs at build time, so the headers never shipSet the headers in _headers
X-XSS-Protection: 1; mode=blockThe header can create XSS vulnerabilitiesDon’t send the header
HSTS preload on the first deployA subdomain without HTTPS becomes unreachable for monthsIncrease max-age in stages, and preload only with owner approval
No not_found_handling in the Wrangler configUnknown paths return a 404 with an empty bodySet not_found_handling = "404-page"
npm install -D typescript with no versionTypeScript 7 installs, and astro check failsInstall typescript@^6
Tailwind through PostCSSThe production build failsUse @tailwindcss/vite
Tests in astro dev onlyCSP, headers, redirects, and slash handling fail in productionTest npm run build with npx wrangler dev

The pass signal is a build with exit code 0 and no [ERROR] line in its log. The build script runs astro check first.

Terminal window
npm run build > build.log 2>&1; echo "exit: $?"
grep -F '[ERROR]' build.log

The expected results:

  • The first command prints exit: 0.
  • The grep command prints nothing.

Then run these checks against dist/:

Terminal window
test -f dist/sitemap-index.xml && test -f dist/robots.txt && test -f dist/404.html
grep -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 test command exits with code 0.
  • Each grep command prints nothing.
  • lychee reports no errors.

Then check the deployed site:

Terminal window
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-control
curl -sI https://SITE/about | grep -iE '^(http|location)'
curl -sI https://SITE/does-not-exist/ | head -1

Replace the following:

  • SITE: the production host name.
  • FILE: the name of any file in dist/_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 location of /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.

Use this checklist for each pull request.

  • The page files contain no <style> block and no styled markup.
  • Markup that appears twice is a component.
  • Each primitive has interface Props, accepts class, and spreads ...rest onto its root.
  • No element spreads ...rest and 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.
  • 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 JsonLd primitive.
  • 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.
  • Content images come from src/, not from public/.
  • Each image has alt, and decorative images use alt="".
  • Only the LCP image has priority.
  • Grid images and every <Picture> pass sizes.
  • Fonts load through the Fonts API.
  • Internal links go through Link and end with a slash.
  • New redirects are in public/_redirects with a 301 status.
  • lychee passes on dist/.
  • 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.
  • site is the production origin.
  • dist/sitemap-index.xml lists only pages that must be indexed.
  • robots.txt points to the production sitemap.
  • The production HTML has no noindex meta 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.