# 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

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 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](https://docs.astro.build/en/basics/project-structure/), [islands architecture](https://docs.astro.build/en/concepts/islands/), [`trailingSlash`](https://docs.astro.build/en/reference/configuration-reference/#trailingslash).

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

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

## Parts

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

```text
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](https://docs.astro.build/en/basics/project-structure/), [upgrade to Astro 7](https://docs.astro.build/en/guides/upgrade-to/v7/).

## Build order

### 1. Create the project

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

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

```json
{
  "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](https://docs.astro.build/en/install-and-setup/), [TypeScript](https://docs.astro.build/en/guides/typescript/), [CLI reference](https://docs.astro.build/en/reference/cli-reference/), [experimental flags](https://docs.astro.build/en/reference/experimental-flags/), [build with AI](https://docs.astro.build/en/guides/build-with-ai/).

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

```js
// 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`](https://docs.astro.build/en/reference/configuration-reference/#site), [`trailingSlash`](https://docs.astro.build/en/reference/configuration-reference/#trailingslash), [`build.format`](https://docs.astro.build/en/reference/configuration-reference/#buildformat), [`prerenderConflictBehavior`](https://docs.astro.build/en/reference/configuration-reference/#prerenderconflictbehavior), [`astro:config`](https://docs.astro.build/en/reference/modules/astro-config/).

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

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](https://docs.astro.build/en/basics/astro-components/), [official portfolio example](https://github.com/withastro/astro/tree/main/examples/portfolio/src/components).

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

```astro
---
// 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](https://docs.astro.build/en/guides/typescript/#component-props), [passing a class to a child component](https://docs.astro.build/en/guides/styling/#passing-a-class-to-a-child-component), [`class:list`](https://docs.astro.build/en/reference/directives-reference/#classlist).

#### 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. 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](https://docs.astro.build/en/basics/astro-components/#slots).

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

| 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 `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](https://docs.astro.build/en/basics/layouts/), [nesting layouts](https://docs.astro.build/en/basics/layouts/#nesting-layouts), [Markdown layouts](https://docs.astro.build/en/basics/layouts/#markdown-layouts).

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

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

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

#### Keep non-production pages out of search

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

#### Add structured data through a component

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

```astro
---
// 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`](https://github.com/withastro/astro/blob/main/examples/blog/src/components/BaseHead.astro), [`Astro.site`](https://docs.astro.build/en/reference/api-reference/#site), [`set:html`](https://docs.astro.build/en/reference/directives-reference/#sethtml), [Google: canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls), [Google: title links](https://developers.google.com/search/docs/appearance/title-link), [Google: favicons](https://developers.google.com/search/docs/appearance/favicon-in-search), [Google: block indexing](https://developers.google.com/search/docs/crawling-indexing/block-indexing), [Google: localized versions](https://developers.google.com/search/docs/specialty/international/localized-versions), [Open Graph](https://ogp.me/).

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

```ts
// src/content.config.ts
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.

#### 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:

- `.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](https://docs.astro.build/en/guides/content-collections/#when-not-to-create-a-collection), [individual Markdown pages](https://docs.astro.build/en/guides/markdown-content/#individual-markdown-pages), [MDX integration](https://docs.astro.build/en/guides/integrations-guide/mdx/), [MDX security](https://mdxjs.com/docs/getting-started/#security), [Markdoc integration](https://docs.astro.build/en/guides/integrations-guide/markdoc/).

#### Render Markdown through components

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

#### 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](https://docs.astro.build/en/guides/content-collections/), [content loader reference](https://docs.astro.build/en/reference/content-loader-reference/), [Markdown](https://docs.astro.build/en/guides/markdown-content/), [MDX](https://docs.astro.build/en/guides/integrations-guide/mdx/).

### 7. Add images

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

```js
// 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.

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

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

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

#### Control the build time

- 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](https://docs.astro.build/en/guides/images/), [`astro:assets`](https://docs.astro.build/en/reference/modules/astro-assets/), [image options](https://docs.astro.build/en/reference/configuration-reference/#image-options), [`cacheDir`](https://docs.astro.build/en/reference/configuration-reference/#cachedir), [web.dev: optimize LCP](https://web.dev/articles/optimize-lcp).

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

```js
// astro.config.mjs
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`:

```astro
<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](https://docs.astro.build/en/guides/fonts/), [font configuration](https://docs.astro.build/en/reference/configuration-reference/#fonts).

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

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

#### Prefetch and page transitions

Turn on prefetch, and opt in the main navigation links with `data-astro-prefetch`:

```js
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

- 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 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](https://docs.astro.build/en/guides/prefetch/), [view transitions](https://docs.astro.build/en/guides/view-transitions/), [external links recipe](https://docs.astro.build/en/recipes/external-links/), [routing](https://docs.astro.build/en/guides/routing/), [`redirects`](https://docs.astro.build/en/reference/configuration-reference/#redirects), [MDN: `noopener`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/noopener), [MDN: MPA view transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#basic_mpa_view_transition), [lychee](https://github.com/lycheeverse/lychee).

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

```js
// astro.config.mjs
sitemap({ filter: (page) => !noindexPaths.includes(new URL(page).pathname) }),
```

Generate `robots.txt` from `site` with an endpoint:

```ts
// src/pages/robots.txt.ts
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](https://docs.astro.build/en/guides/integrations-guide/sitemap/), [Google: build a sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap).

### 11. Add interactivity

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](https://docs.astro.build/en/guides/client-side-scripts/), [framework components](https://docs.astro.build/en/guides/framework-components/), [client directives](https://docs.astro.build/en/reference/directives-reference/#client-directives), [sharing state between islands](https://docs.astro.build/en/recipes/sharing-state-islands/).

### 12. Set the HTTP headers and the CSP

#### Write the host headers

Put the response headers in `public/_headers`. Astro copies the file to `dist/`, and Cloudflare and Netlify apply it.

```txt
/*
  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.

#### Turn on Astro CSP

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

```js
// 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.

#### 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`](https://docs.astro.build/en/reference/configuration-reference/#securitycsp), [CSP runtime API](https://docs.astro.build/en/reference/api-reference/#csp), [middleware](https://docs.astro.build/en/guides/middleware/), [Cloudflare: headers](https://developers.cloudflare.com/workers/static-assets/headers/), [Cloudflare: HSTS](https://developers.cloudflare.com/ssl/edge-certificates/additional-options/http-strict-transport-security/), [OWASP HTTP headers cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html), [CSP: `meta` element limits](https://w3c.github.io/webappsec-csp/#meta-element), [MDN: multiple policies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#multiple_content_security_policies), [hstspreload.org](https://hstspreload.org/).

### 13. Configure the host and CI

For a fully static site on Cloudflare, don't install `@astrojs/cloudflare`. Commit an explicit Wrangler config:

```toml
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](https://docs.astro.build/en/guides/deploy/cloudflare/), [Cloudflare adapter](https://docs.astro.build/en/guides/integrations-guide/cloudflare/), [Cloudflare: HTML handling](https://developers.cloudflare.com/workers/static-assets/routing/advanced/html-handling/), [Cloudflare: redirects](https://developers.cloudflare.com/workers/static-assets/redirects/), [Vercel: `trailingSlash`](https://vercel.com/docs/project-configuration/vercel-json#trailingslash).

## Minimum viable version

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.

## Change workflow

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

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

| 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

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

```sh
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/`:

```sh
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:

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

### Review checklist

Use this checklist for each pull request.

#### Components

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

#### 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 `JsonLd` primitive.

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

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

#### Links

- [ ] Internal links go through `Link` and end with a slash.
- [ ] New redirects are in `public/_redirects` with a 301 status.
- [ ] `lychee` passes on `dist/`.

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

- [ ] `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.

## Sources

- [Astro 7 release](https://astro.build/blog/astro-7/)
- [Upgrade to Astro 7](https://docs.astro.build/en/guides/upgrade-to/v7/)
- [Upgrade to Astro 6](https://docs.astro.build/en/guides/upgrade-to/v6/)
- [Configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
- [Astro changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Layouts](https://docs.astro.build/en/basics/layouts/)
- [Styling](https://docs.astro.build/en/guides/styling/)
- [TypeScript](https://docs.astro.build/en/guides/typescript/)
- [Content collections](https://docs.astro.build/en/guides/content-collections/)
- [Markdown](https://docs.astro.build/en/guides/markdown-content/)
- [MDX integration](https://docs.astro.build/en/guides/integrations-guide/mdx/)
- [Images](https://docs.astro.build/en/guides/images/)
- [Fonts](https://docs.astro.build/en/guides/fonts/)
- [Routing](https://docs.astro.build/en/guides/routing/)
- [Prefetch](https://docs.astro.build/en/guides/prefetch/)
- [View transitions](https://docs.astro.build/en/guides/view-transitions/)
- [Sitemap integration](https://docs.astro.build/en/guides/integrations-guide/sitemap/)
- [Client-side scripts](https://docs.astro.build/en/guides/client-side-scripts/)
- [Framework components](https://docs.astro.build/en/guides/framework-components/)
- [Middleware](https://docs.astro.build/en/guides/middleware/)
- [Deploy to Cloudflare](https://docs.astro.build/en/guides/deploy/cloudflare/)
- [Official blog example](https://github.com/withastro/astro/tree/main/examples/blog)
- [Cloudflare Workers static assets: headers](https://developers.cloudflare.com/workers/static-assets/headers/)
- [Cloudflare Workers static assets: HTML handling](https://developers.cloudflare.com/workers/static-assets/routing/advanced/html-handling/)
- [OWASP HTTP headers cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html)
- [Google Search Central: canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)
- [Google Search Central: sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap)
- [web.dev: optimize LCP](https://web.dev/articles/optimize-lcp)