Skip to content

Claude Code Astro DevelopmentStep 2 of 2

How to Build an Astro Website With Claude Code: Production Workflow

Astro is close to the best-case platform for agent-assisted work, because almost everything the site does is visible in files. This is a production build end to end, against Astro 7's current content collections API.

Claude Code Guides: How to Build an Astro Website With Claude Code. A page card with a highlighted content block, beside three smaller blocks queueing to join it, the first highlighted.

Why Astro is the easiest platform to hand an agent

Astro is close to the best-case platform for agent-assisted development, and the reason is structural rather than aesthetic: almost everything the site does is visible in files. There is no database, no plugin layer filtering your output on the way out, and no admin interface quietly overriding what you wrote. Read the repository and you know what the site renders.

That single property changes the working relationship. On WordPress, the hard part is establishing which of three sources of truth is in charge. On Astro, the hard part is the ordinary one — being specific about what you want and having a check that can tell you whether you got it.

The second reason is the output. Astro ships zero JavaScript by default, so the failure mode where an agent adds a client-side dependency to solve a problem that did not need one is loudly visible: the bundle appears where there was none. That is a much better feedback loop than a framework where a little more JavaScript is invisible.

This guide covers a production build end to end. It assumes Astro 7, which is the current major line, and it flags the places where behaviour has changed enough that older advice is actively wrong.

Starting: a new project, or one that already exists

For a new project, let the official scaffold do the scaffolding. An agent writing project boilerplate from memory reproduces whatever version it learned, and Astro's has changed materially between majors.

npm create astro@latest

Then hand the agent a repository that already runs, and start from reconnaissance rather than from features. The first session should change nothing:

Analyse this Astro repository. Do not modify anything.

Report, with the file and line as evidence for each point:

1. The Astro version from package.json and the lockfile, and whether
   they agree.
2. The output mode in astro.config: static, server, or hybrid, and
   which adapter is configured if any.
3. Every integration installed, and what each one adds to the build.
4. Whether content collections are defined, where the config lives,
   and which loader each collection uses.
5. Every route, grouped into static and dynamic, and for dynamic
   routes the getStaticPaths source.
6. Every component that carries a client:* directive, with the
   directive used and the framework it pulls in.
7. Whether TypeScript is in strict mode.
8. The build, dev, and any test or lint commands.

Then state in one paragraph how much JavaScript this site ships to a
visitor on the homepage, and where it comes from. If you cannot tell
without building, say so and stop.

That last question is the one worth asking first. An Astro site shipping 200 KB of client JavaScript is an Astro site being used as a worse React app, and it is better to know before you add to it.

The architecture worth stating out loud

Three ideas do most of the work, and an agent will produce better output if the project context file names them explicitly rather than leaving them to be inferred.

Components render at build time by default. An .astro component runs its frontmatter on the server, produces HTML, and ships no JavaScript. This is the default and it should stay the default. Most interactive-looking things — a details/summary disclosure, a navigation menu, a form — do not need a framework component at all.

Islands are opt-in and directive-scoped. A framework component becomes interactive only when you add a client:* directive, and which directive you choose is a performance decision: client:load hydrates immediately, client:idle waits for the main thread, client:visible waits until it scrolls into view, and client:only skips server rendering entirely. Agents reach for client:load because it always works. Most components should be client:visible.

Output mode decides what is possible. A statically built site has no server at request time, so anything needing a request — reading a cookie, personalising a response, handling a form POST — requires either a server-rendered route or a separate endpoint. Deciding this late is expensive; deciding it in the first session is free.

Content collections, as they actually are now

This is the part where stale advice does the most damage, because the API changed and the old shape still appears in a great deal of writing.

Collections are defined in src/content.config.ts, and every collection now requires a loader. The built-in ones are glob() for many files matching a pattern and file() for a single file containing multiple entries; custom loaders can pull from anywhere. The schema remains optional and is written with Zod.

import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const guides = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/guides' }),
  schema: z.object({
    title: z.string().max(70),
    description: z.string().min(70).max(158),
    published: z.coerce.date(),
    updated: z.coerce.date().optional(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { guides };

Query them with getCollection() and getEntry(), and turn an entry's body into HTML with render(). Astro 7 adds live collectionsgetLiveCollection() and getLiveEntry() — which fetch at request time rather than at build, for data that must be fresh. Build-time collections and live collections use similar APIs and run at different stages; picking the wrong one produces either stale content or a site that cannot be statically built.

The schema is the most useful thing on this page for agent-assisted work, and it is underused. It is a check that runs on every build and cannot be skipped. The example above will fail the build if a description is shorter than 70 characters or longer than 158 — which means "every guide has a meta description of a sensible length" stops being a review item and becomes a build error.

Add three new guides to the `guides` collection from the outlines in
`/tmp/outlines.md`.

The collection schema is authoritative. Do not modify
`src/content.config.ts` to make content fit — if a value will not
validate, fix the content.

After writing them, run `npm run build` and show me the output. If the
build fails on schema validation, fix the frontmatter and build again.
Do not relax the schema.

"Do not relax the schema" belongs in the project context file permanently. Loosening a constraint to make a build pass is the agent-assisted equivalent of deleting a failing test, and it is just as tempting.

Routing, layouts, and the shape of a page

Routing is file-based: src/pages/about.astro becomes /about. Dynamic routes use bracket syntax and, in a static build, must export getStaticPaths() to enumerate every page that will exist.

---
// src/pages/guides/[...slug].astro
import { getCollection, render } from 'astro:content';
import Layout from '../../layouts/Guide.astro';

export async function getStaticPaths() {
  const guides = await getCollection('guides', ({ data }) => !data.draft);
  return guides.map((entry) => ({
    params: { slug: entry.id },
    props: { entry },
  }));
}

const { entry } = Astro.props;
const { Content } = await render(entry);
---
<Layout title={entry.data.title} description={entry.data.description}>
  <Content />
</Layout>

Two things in that snippet are worth making rules. The draft filter belongs in getStaticPaths(), not in the template — filtering in the template still generates the page, it just renders it empty, which produces a thin indexable URL. And every page's title and description flow from the collection schema into the layout, which means the schema constraint reaches the rendered <head>.

Layouts are ordinary components that accept a slot. One layout owning the <head> is the single most valuable structural decision in an Astro project, because it makes "every page has a canonical URL" a property of the codebase rather than a thing to check.

TypeScript, and using it as a check

Astro ships TypeScript configuration presets and astro check to run them. Turn on the strict preset, and treat astro check as part of the build rather than an optional nicety:

npm run astro check && npm run build

The reason this matters more with an agent in the loop than without one: type errors are the cheapest possible feedback signal, and they catch exactly the class of mistake an agent makes most often — a property renamed in one place and not another, a possibly-undefined value used directly, a component prop that no longer exists. Without the check, those surface as a blank region on a page nobody looked at.

Content collections generate types from the schema, so a typo in entry.data.titel is a compile error rather than undefined rendered into a title tag.

Images and assets

Astro's <Image /> component handles format conversion, responsive sizes and, importantly, emits width and height so the browser reserves space. Layout shift from unsized images is the most common CLS failure on content sites, and using the component makes it structurally impossible for images it handles.

---
import { Image } from 'astro:assets';
import diagram from '../assets/build-pipeline.png';
---
<Image src={diagram} alt="..." widths={[400, 800, 1200]}
       sizes="(min-width: 60rem) 800px, 92vw" loading="lazy" />

Three rules to put in project context:

  • Images imported from src/ are processed and hashed. Images in public/ are copied verbatim and are not optimised — that directory is for files that must keep an exact path, like robots.txt or a verification file.
  • The element that will be the largest contentful paint must not be lazy-loaded. Everything below the fold should be.
  • alt is required by the component's types, which is a small piece of accessibility enforced by the compiler. Do not defeat it with alt="" on a meaningful image; empty alt is correct only for genuinely decorative images.

Integrations, and how few you need

Every integration is build-time code, a dependency to update, and a thing that can break on the next major. The set worth having on a content site is small:

Integration Worth it when
@astrojs/sitemap Almost always. Generates sitemap-index.xml at build; needs site set in the config.
@astrojs/mdx Content genuinely needs components inline. Plain Markdown is faster and simpler if it does not.
A UI framework Only when a component is genuinely interactive. One framework, not two.
An adapter Only if the output mode is server or hybrid.

Setting site in astro.config.mjs is not optional. Without it, the sitemap integration produces nothing useful and Astro.site is undefined, which means canonical URLs silently render as relative paths. That is a real SEO defect that produces no error at build time.

SEO

The whole of a static site's SEO surface lives in a layout you control, which is why Astro sites can be genuinely excellent at this with very little work. Put the head in one component and make every page pass through it.

---
// src/components/Head.astro
interface Props { title: string; description: string; image?: string; }
const { title, description, image } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:type" content="article" />
{image && <meta property="og:image" content={new URL(image, Astro.site)} />}

Building the canonical from Astro.url.pathname and Astro.site means it is correct on every page by construction, including dynamic routes, and it is absolute — a relative canonical is ignored by some crawlers and is a common silent failure.

The remaining decisions are editorial rather than technical: one h1 per page that names the page's actual subject, heading order that does not skip levels, and a decision about which generated listing pages should be indexed. Tag and category archives on a small site usually should not be — they duplicate the main listing without adding text. The technical SEO audit workflow covers how to verify the output rather than trusting the source.

Structured data

Emit JSON-LD from the same layout, built from the same data the page renders, so the markup and the visible content cannot disagree. That is the entire discipline. A price, a date or a headline typed into a JSON-LD block by hand is correct until the day it is not.

<script type="application/ld+json" set:html={JSON.stringify({
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: entry.data.title,
  description: entry.data.description,
  datePublished: entry.data.published.toISOString(),
  dateModified: (entry.data.updated ?? entry.data.published).toISOString(),
  mainEntityOfPage: canonical.href,
})} />

set:html is the correct directive here — it inserts the string without escaping, which is what a JSON-LD block needs and would be dangerous for user content. Two rules: never mark up something a visitor cannot see, and never add a field because a validator suggested it. A validator will happily propose aggregateRating; adding it without reviews is fabrication with a schema.org wrapper.

Accessibility

Astro's default output is semantic HTML, which starts you in a good position. The failures that do appear are the ones introduced by islands: a component that renders as a div with a click handler, a modal that does not trap focus, a disclosure built from JavaScript state rather than details/summary.

The most effective single rule is to prefer native elements, and to make the agent justify not using one:

Add a frequently-asked-questions section to the pricing page, with each
answer collapsible.

Use native `<details>` and `<summary>`. Do not add a client:* directive
and do not introduce a framework component — if you believe this cannot
be done without JavaScript, stop and explain why rather than adding it.

All answers must be present in the HTML whether or not they are open,
so the content is readable without JavaScript and crawlable.

Then split the audit the way any accessibility audit should be split: what a machine can answer — contrast, labels, heading order, alt presence, target size — from what needs a person, which is whether alt text is useful and whether a flow can be completed by keyboard. The WCAG workflow covers the division and the exceptions that trip up automated checkers.

Performance

A static Astro site starts fast and gets slower only through decisions you make. The three that matter:

Hydration. Every client:* directive is a bundle. Audit them as a list rather than one at a time — the question is not "is this island justified" but "how many islands does this page have and do they need to hydrate at the same moment".

Fonts. A web font is render-blocking or it causes a swap, and either way it is the most common cause of layout shift on an otherwise clean build. Self-host, preload the one face that appears above the fold, and set font-display: swap with a metric-compatible fallback stack.

Third-party scripts. One analytics tag is a decision; four tag-manager containers are a performance problem you cannot fix in your own code. Astro makes your own JavaScript small enough that third-party scripts become the dominant cost, which is a good problem and still a problem.

The targets are unchanged: LCP at or under 2.5 seconds, INP at or under 200 milliseconds, CLS at or under 0.1, each at the 75th percentile of real loads, segmented across mobile and desktop. Lab tools cannot meaningfully measure INP because it depends on what real people click. The Core Web Vitals workflow covers diagnosis in detail.

A CLAUDE.md for Astro

# CLAUDE.md

## Project
Astro 7 content site. Static output, deployed to Cloudflare Workers.
Content lives in `src/content/guides` as Markdown with a Zod schema.

## Commands
- `npm run dev` — local dev server
- `npm run build` — production build, must pass before any commit
- `npm run astro check` — TypeScript and Astro diagnostics, must be clean
- `npm run preview` — serve the built output locally

## Architecture
- Components are `.astro` and render at build time. This is the default
  and stays the default.
- A `client:*` directive is a performance decision, not a convenience.
  Default to `client:visible`. Never `client:load` without a reason
  stated in the pull request.
- One UI framework only. Do not add a second.
- `site` is set in astro.config and canonical URLs are built from it.
  Never write a relative canonical.

## Content
- `src/content.config.ts` is authoritative. If content will not validate,
  fix the content. Do not relax the schema to make a build pass.
- Drafts are filtered in `getStaticPaths()`, never in the template —
  filtering in the template still generates an empty page.

## Images
- Import from `src/` and use `` so dimensions are emitted.
- `public/` is only for files that need an exact path (robots.txt,
  verification files). Nothing there is optimised.
- The LCP image is never lazy-loaded. Everything below the fold is.

## Prohibited
- No new dependency without saying what it replaces and what it costs
  in shipped bytes.
- No `any` in TypeScript. If a type is genuinely unknown, use `unknown`
  and narrow it.
- No structured data for content that is not visible on the page.

## Definition of done
- `astro check` clean, `npm run build` succeeds.
- No new client-side JavaScript unless it was the point of the change.
- Verified in `npm run preview`, not only in dev — the dev server and
  the built output differ in ways that matter.

The last line is worth dwelling on. The Astro dev server does not produce the same output as a build: image processing, prerendering and integration behaviour all differ. A change verified only in dev has not been verified.

Testing, and checks that can fail

The most valuable checks on a content site are structural and cheap. In rough order of return:

  1. The build passes. With a schema on every collection, this already covers a great deal.
  2. astro check is clean. Types catch renames and undefined access.
  3. Every internal link resolves. Crawl the built output in dist/ — a link checker that runs against source is checking the wrong artefact.
  4. Every page has a title, a description and a canonical. Three greps over dist/, and they will find the one page that slipped past the layout.
  5. No page has more than one h1. Same method, same cost.

Whatever you write, prove it can fail before you trust it passing. Break a link deliberately, run the checker, confirm it goes red, then restore it and confirm it goes quiet. A link crawler reporting "0 broken links" that has never found one is not evidence of anything — and on a static site it is very easy to write one that silently checks nothing because it was pointed at the wrong directory.

Deploying to Cloudflare

This is the part of Astro deployment advice most likely to be out of date, so it is worth being precise. Cloudflare's documentation now states plainly that Workers supports most Pages use cases and is Cloudflare's primary platform for building applications, and that new projects should start with Workers. Pages still exists and existing projects still work; it is simply no longer the default recommendation.

For a statically built Astro site, that means deploying the build output as Workers Static Assets. The configuration is a wrangler.jsonc naming the directory the build produced:

{
  "name": "acme-site",
  "compatibility_date": "2026-09-01",
  "assets": { "directory": "./dist" }
}

Deploying uploads the Worker and the assets in one operation. From CI, the supported action is cloudflare/wrangler-action@v3, which defaults to Wrangler v4 and authenticates with an API token and an account ID supplied as repository secrets — CI is non-interactive, so there is no browser login to fall back on.

- name: Deploy
  uses: cloudflare/wrangler-action@v3
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

If the site needs server-rendered routes, add @astrojs/cloudflare as the adapter and set the output mode accordingly. Do not add the adapter to a purely static site: it changes the build target for no benefit and makes a simple deployment more complicated. The full pipeline — branch strategy, validation gates, previews, rollback — is covered in the GitHub Actions and Cloudflare CI/CD guide.

Production validation

Check the deployed URL, not the preview, after caches have settled. The differences that matter between a local preview and production are precisely the ones that are hardest to see: canonical URLs, redirect behaviour, robots directives, and response headers.

curl -sI https://example.com/ | head -1
curl -s https://example.com/ | grep -o 'rel="canonical" href="[^"]*"'
curl -s https://example.com/robots.txt
curl -sI https://example.com/does-not-exist | head -1   # expect 404, not 200
curl -s https://example.com/sitemap-index.xml | head -3

That fourth line catches a specific and common static-hosting misconfiguration: a single-page-app fallback that serves the index page with a 200 for every unknown path. On a content site that turns every typo and every stale external link into an indexable duplicate of the homepage.

The mistakes that cost the most

Relaxing the schema to make a build pass. The schema was the check. Loosening it converts a build failure into a silent content defect.

client:load everywhere. It always works, which is why it is the default an agent reaches for, and it is the reason an Astro site ends up shipping more JavaScript than the React app it replaced.

Forgetting site in the config. No error, no warning, and canonical URLs quietly become relative while the sitemap integration produces nothing useful.

Verifying in dev only. Image handling and prerendering differ between the dev server and the build. Preview the built output.

Putting images in public/. They bypass optimisation entirely, and the problem is invisible until someone measures the page weight.

Filtering drafts in the template. The page is still generated. It is simply empty, indexable, and thin.

End to end: a documentation site in one sitting

A realistic sequence, with the checks in the places they belong:

  1. Scaffold. npm create astro@latest, minimal template, TypeScript strict. Commit before anything else, so the diff of everything that follows is readable.
  2. Write CLAUDE.md first. Commands, architecture rules, the schema-is-authoritative rule, the hydration rule. Then ask Claude what the file says about hydration; a vague answer means a vague section.
  3. Define the collection before writing any content. Schema with title and description length limits, published and updated dates, a draft flag. The constraints you want on every page become build failures rather than review comments.
  4. Build the layout and the head component. One component owns title, description, canonical, Open Graph and JSON-LD, built from the entry data. Every page routes through it.
  5. Add the dynamic route. getStaticPaths() filtering drafts, render() for the body.
  6. Content. Now the schema is doing work: a description that is too short fails the build.
  7. The listing page and navigation. Plain .astro, no islands.
  8. Sitemap and robots.txt. Set site, add the integration, put robots.txt in public/.
  9. Checks. astro check, a build, a link crawl over dist/, and the head-tag greps. Break one thing deliberately and confirm each check notices.
  10. Deploy. Wrangler config with assets.directory pointing at dist, deploy from CI with the wrangler action.
  11. Validate live. Response codes, canonicals, the 404 behaviour, the sitemap. Then again the next day, once a crawler has had a turn.

The reason Astro rewards this way of working is that its defaults agree with it. A build that fails on a bad description, a type checker that catches a renamed property, and a bundle that visibly grows when someone adds an island are all checks you did not have to invent. Use them, and the agent's output becomes something you can verify rather than something you have to trust.

Read these next

See how this fits into Claude Code Astro Development

Free download

The CLAUDE.md Starter Kit, free

Four working CLAUDE.md files you can drop into a project today, plus the one-page checklist for what belongs in one and how to tell whether yours is actually working.

  • CLAUDE.md for a static marketing site
  • CLAUDE.md for a web application, with security and migration rules
  • CLAUDE.md for a Shopify theme, including the gotchas that cost hours
  • CLAUDE.md for a shared package in a monorepo
  • A one-page checklist, and how to test the file is actually working
What are you working on?

The download appears here as soon as you submit. I will also email you when there is a new guide worth reading. No fixed schedule, no selling your address, unsubscribe from any email. See the privacy policy.