# How to build a website with Claude Code: the full production guide

> Build a production website with Claude Code: rules file, guardrails, design system, database, blog, publish endpoint, migration and SEO, with prompts.

Source: https://contengi.com/blog/how-to-build-a-website-with-claude-code
Author: Stefan Maritz
Published: 2026-10-07
Updated: 2026-10-08

Every Claude Code website tutorial stops at a landing page on Vercel. This is the rest of it: the architecture, guardrails, and prompts behind three production marketing sites run by people who never open a terminal.

## TL;DR

-   Claude Code can build and run a full production marketing site, with a design system, a database-backed blog, a CMS, and a signed publishing endpoint, provided you give it rules before you give it work
-   Write a CLAUDE.md first. It's the single most useful file in the repo, because Claude reads it at the start of every session
-   Put guardrails in code: git hooks that block commits to main, a guard script that catches leaked keys, page weight budgets, and Lighthouse in CI
-   Keep page copy in code and blog posts in a database, so posts publish, change, and disappear without a redeploy
-   A publish endpoint should verify an HMAC signature over the raw body, treat the slug as the post's identity, and copy images into your own storage
-   Migrate an old blog with a deterministic scraper, a marker column so re-runs are safe, and one CSV as the single source for redirects
-   Check everything in a real browser. Typechecks and green builds missed every one of the bugs that reached our users

## The first afternoon is the easy part

Every guide to building a website with Claude Code ends in the same place: a landing page on Vercel and a screenshot of a Lighthouse score. That's the first afternoon. We've shipped three production marketing sites this way in 2026, a consultancy, a B2B services firm, and our own, and the afternoon was the bit that went smoothly.

The work lives in everything after it. A blog the marketing team can publish to without calling anyone, a 260-post migration that keeps every ranking, a design system that stops the site drifting page by page, and guardrails strong enough that a non-developer can run the whole thing through Claude without leaking a key or breaking production.

This guide covers all of it, layer by layer, with the prompts we'd use at each step. The architecture works for any brand, so swap in your own names and colours. If you're still choosing tools, start with our [AI-native website stack guide](https://contengi.com/blog/ai-native-website-stack). If you're moving an existing site, read the [migration guide](https://contengi.com/blog/migrate-website-to-next-js) alongside this one, since it goes deeper on URL inventories and redirects.

## The architecture on one page

Before any prompting, know what you're building. All three of our sites share the same shape:

-   **Framework:** Astro or Next.js, with static pages by default
-   **Styling:** Tailwind 4 driven by your own design tokens, with Tailwind's default palette switched off
-   **Hosting:** Vercel, with a preview deployment for every branch
-   **Data:** Supabase Postgres for blog posts, images, and form leads, protected by row level security
-   **Content in two homes:** page copy lives in code, where Claude edits it, and blog posts live in the database, where editors and publishing tools reach them without a deploy
-   **One signed publish endpoint** so outside tools can create, update, unpublish, and delete posts
-   **Guardrails:** git hooks, a guard script, page budgets, and Lighthouse in CI

The split in the content layer is the decision everything else hangs on. Pages change when the business changes, which is a few times a month, and a pull request is the right tool for that. Posts change daily, and nobody should open a pull request to fix a typo in paragraph four.

## Step one: pick Astro or Next.js

Both work. We run one site on Next.js and two on Astro, and the choice came down to what the site does beyond pages.

Astro ships zero JavaScript unless you ask for it, which makes it the better default for a site that's mostly pages and a blog. Our Astro build scores 100 for mobile performance with a 1.4 second largest contentful paint, and we didn't have to fight for it. Next.js earns its place when the site behaves like an app: logins, an interactive assessment that generates a PDF report, results pages people can share. That's why our consultancy site runs on it.

One practical note. Claude knows Next.js extremely well because there's years of it in the training data. Astro moves fast, and version 7 is newer than most models have seen, so tell Claude to read the [Astro docs](https://docs.astro.build) before it relies on memory. Put that instruction in the rules file and you'll never have to repeat it.

```
Scaffold a new Astro 7 marketing site in this folder. Static output with the Vercel adapter, Tailwind 4 through the Vite plugin, TypeScript strict. Read docs.astro.build for anything version-specific before writing code, since v7 is newer than your training data. Do not add React or any UI framework. Create src/pages, src/layouts, src/components, src/server (server-only code), src/pages/api, src/assets, and supabase/migrations. Set prefetch on hover sitewide. Then stop and show me the tree.
```

## Step two: write the rules before the code

The most useful file in any of our repos is `CLAUDE.md`. Claude Code loads it into [memory at the start of every session](https://docs.claude.com/en/docs/claude-code/memory), so everything in it is a standing instruction. Skip it and you'll find yourself typing the same corrections every Tuesday.

Ours open with who works in the repo, and that line changes Claude's behaviour more than anything else in the file. On one site it says the people working there are mostly marketers and designers, usually working through Claude, who can't debug a failed build or a leaked key on their own. Claude responds by keeping changes small, explaining in plain language, and stopping to ask before anything hard to undo.

Then the priorities, in order: safe, fast, pretty. A marketing site that's slow or leaks a key has failed, however good it looks. After that, the file covers:

-   The stack, the folder structure, and where server-only code is allowed to live
-   Git rules, starting with never committing or pushing to main and never merging anything without a person
-   Speed rules: zero JavaScript by default, self-hosted fonts, no large images in the public folder, every third-party script justified
-   SEO rules: one h1 per page, titles under 60 characters, descriptions under 160, a 301 in the same pull request whenever a live URL changes
-   An image standard: file names describe the image in lowercase hyphenated words, alt text describes what the image shows, decorative images get an empty alt on purpose
-   Which pages are real and which are design concepts, so Claude never edits the wrong home page

That last one came from experience. We built redesign concepts at URLs ending in `-2`, set them to noindex, and kept them out of the sitemap. Without a line in CLAUDE.md saying which version was the live site, a fresh session would happily polish the concept and leave the real page alone.

```
Write a CLAUDE.md for this repo. The people working here are marketers, not developers, and they work through you. Priorities in order: safe, fast, pretty. Include: the stack and folder structure; that server secrets may only be read in src/server and src/pages/api; git rules (never commit or push to main, always branch from an up-to-date main, never merge, one branch per change); speed rules (zero JS by default, self-hosted woff2 fonts, no images over 300 KB in public/, every third-party script needs a reason); SEO rules (one h1, titles under 60 characters, descriptions under 160, a 301 in the same PR when a live URL changes); and an image naming and alt text standard. Keep it under 400 lines and write it for someone who will read it once.
```

Treat CLAUDE.md as living documentation. Every time a session teaches you something the hard way, add the lesson in the same pull request as the fix.

## Step three: build guardrails that run without you

Rules in a markdown file are suggestions. Rules in a git hook are rules. When a non-developer drives the repo through Claude, the guardrails are what keep a Friday afternoon tweak from becoming a Monday morning incident.

We run four layers:

-   **A pre-commit hook** that blocks commits on main, runs the guard script on staged files, and formats with Prettier
-   **A pre-push hook** that blocks pushes to main, refuses a branch that conflicts with main, and runs lint and type checks
-   **CI on every pull request** that repeats all of it, builds the site, checks page budgets, and runs Lighthouse on mobile
-   **A weekly job** that audits dependencies and lets Dependabot open update PRs

The guard script carries the most weight. It blocks committed `.env` files, API keys and private tokens by pattern, secret-looking variables carrying a `PUBLIC_` prefix (which would ship them to every browser), server-only environment variables read outside the server folders, third-party script hosts that aren't on an allowlist, images over 300 KB in the public folder, files over 10 MB, and leftover merge conflict markers.

The page budget runs after the build. Any page over 80 KB of JavaScript, 40 KB of CSS, or 60 KB of HTML, all gzipped, fails, and so does any page missing a title, meta description, or `lang` attribute. [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci) then blocks performance under 90, accessibility under 95, best practices under 90, LCP over 2.5 seconds, and CLS over 0.1.

```
Add guardrails to this repo. 1) Husky hooks: pre-commit blocks commits on main, runs scripts/guard.mjs on staged files, and runs Prettier; pre-push blocks pushes to main, previews a merge with origin/main and blocks on conflicts, then runs lint and type checks. 2) scripts/guard.mjs with its settings in scripts/guard.config.mjs: block .env files, API key patterns (Supabase service role, Stripe, GitHub, AWS, Anthropic, OpenAI), PUBLIC_-prefixed secrets, server env vars read outside src/server and src/pages/api, script hosts not on an allowlist, images over 300 KB in public/, files over 10 MB, and conflict markers. 3) scripts/budget.mjs after build: fail any page over 80 KB JS, 40 KB CSS or 60 KB HTML gzipped, or missing title, description or lang. 4) GitHub Actions running all of it plus Lighthouse CI on mobile with these thresholds: performance 90, accessibility 95, best practices 90, LCP 2.5s, CLS 0.1. Add npm scripts start-work, sync, verify and ready.
```

Add one rule to CLAUDE.md alongside the guardrails: loosening a guard is allowed only in its own pull request that explains why. Never inside a feature branch, never to turn a red check green. Claude will reach for "just raise the budget" when a page runs heavy, and that rule stops it.

Know the limits. Hooks can be skipped with `--no-verify`, and GitHub's free plan won't enforce branch protection on a private repository. Until you pay for server-side rules, a red check means "do not merge" by convention only, so write that down where the team will see it.

## Step four: turn the design into a system

A site built page by page from prompts drifts. Buttons gain a pixel here, a grey shifts there, and six weeks later you have three table of contents components that all look slightly different. The fix is a design system that lives in code, with a reference page Claude can read.

### Tokens first, and name them by role

Name colours by what they do (canvas, panel, recess, line, muted, accent) instead of what they look like. That one decision paid for itself when one site needed white sections between its navy ones. We redefined the roles inside a `lane-light` class, and every component inside a white lane re-coloured itself with no new code.

Switch Tailwind's default colours, type sizes, and breakpoints off and replace them with yours. If `bg-blue-500` still exists, Claude will use it eventually. In Tailwind 4 the theme lives in CSS with `@theme`, and component classes go in `@layer components`. Keep plain CSS inside a layer too, because unlayered CSS beats every utility class and you'll spend an hour wondering why the admin screens went white on white.

### Check contrast per surface

A brand colour that sings on navy can fail on white. Our green measured 1.55:1 as text on a white background, nowhere near the 4.5:1 that WCAG AA asks for. The fix was a separate accent token for light surfaces, and it went into the system instead of being patched page by page.

### Build a reference page

Every site gets a `/design-system` route, set to noindex and kept out of the sitemap. It shows the logo, colours, type scale, icons, every component, motion rules, and page blocks, with the markup next to each one. It's documentation for humans and a contract for Claude: "build this page from the blocks on /design-system" produces a page that belongs. When a token or component changes, its chapter changes in the same pull request.

### Self-host your fonts

Download the woff2 files, split them by unicode range, set `font-display: swap`, and preload only the face used above the fold. Google Fonts links add a third-party request to every page. Use as few families as the brand allows. Every extra family is another download competing with your hero image.

```
Read the design handoff in /design-handoff (tokens, components, screenshots). Build the design system: 1) src/styles/theme.css with Tailwind 4 @theme tokens named by role (canvas, panel, recess, line, muted, accent, accent-on-light), with Tailwind's default colours, type sizes and breakpoints reset; 2) component classes in @layer components (wrap, section, label, card, btn, tag, input); 3) a lane-light class that redefines the colour roles for white sections; 4) self-hosted woff2 fonts with font-display swap. Check every text and background pair for WCAG AA contrast and add a separate token where one fails. Then build /design-system as a noindex reference page showing every token and component with its markup. Do not build any marketing pages yet.
```

### Three CSS traps we hit

These cost us real hours, so take them for free. First, `backdrop-filter` on an ancestor makes that element the containing block for any `position: fixed` child. Our mobile menu sat inside a blurred nav bar, so its "full screen" panel measured 390 by 152 pixels and hid seven of nine links. Render overlays as siblings of the nav, never children. The same applies to `transform`, `filter`, and `will-change`, as [MDN's containing block page](https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block) explains.

Second, writing both `backdrop-filter` and `-webkit-backdrop-filter` led our minifier to keep only the prefixed one, so the blur never rendered in Chrome. Third, `content-visibility: auto` is a lovely performance trick until an accessibility checker reads text in a skipped section against the wrong background and fails your contrast score. Use it, then test with it switched off.

## Step five: set up the database properly

Supabase gives you Postgres, storage for images, and auth for the admin in one project. The anon key is public by design, it ships to every visitor's browser, which means [row level security](https://supabase.com/docs/guides/database/postgres/row-level-security) is the only thing protecting your data. Every table gets RLS switched on and explicit policies in the same migration that creates it.

Three more rules we learnt the hard way:

-   **Grant explicitly.** From 30 October 2026, Supabase requires explicit grants on new public tables for its API roles, including `service_role`. Without them, supabase-js returns error 42501 even when a policy would allow the row. Put the grants in every migration
-   **Keep the service role key on the server.** It bypasses RLS completely. Read it only in server code, only from routes that render per request, never in a page or component that could be prebuilt
-   **Keep free projects awake.** A free-tier project pauses after about a week without activity, and our blog went dark the first time it happened. A daily cron that runs a cheap head-only count query keeps it alive at no cost

The blog table design deserves care, since you'll live with it for years. Ours has one row per post with the slug as a unique key, a status column that's the single gate on public visibility, SEO fields that override the defaults when set, cover image alt text stored beside the image, a jsonb FAQ column, and provenance columns showing where each post came from. The only public policy allows reading published rows. There are no write policies at all: every write goes through the server with the service role key, after authenticating.

```
create policy "Published posts are publicly readable"
  on public.blog_posts for select
  to anon, authenticated
  using (status = 'published');

grant select on table public.blog_posts to anon, authenticated;
grant select, insert, update, delete on table public.blog_posts to service_role;
```

```
Create supabase/migrations/0001_blog_posts.sql. One row per post, slug unique and treated as the post's identity. Columns: title, excerpt, hero_intro, body_html, seo_title, meta_description, canonical_url, noindex, cover_image_url, cover_image_alt, tags text[], author fields, faq jsonb, external_links jsonb, estimated_read_time, target_keyword, source ('cms' or 'publisher'), source_id (reference only, never an identity), status ('draft' or 'published'), published_at, created_at, updated_at with a trigger. Enable RLS. One policy: anon and authenticated may select published rows. No write policies. Explicit grants for anon, authenticated and service_role. Make the file safe to re-run. Do not run it: I will paste it into the Supabase SQL editor.
```

Notice the last line of that prompt. We never let Claude run DDL against production. It writes the migration, a person reads it, and a person runs it in the SQL editor.

## Step six: decide how the blog renders

This is the decision that shapes your publishing speed, and there are three sensible answers:

-   **Prebuilt with a deploy hook.** Fastest pages, but every typo fix triggers a full rebuild and a wait of a minute or two before anyone sees it
-   **Incremental static regeneration** in Next.js, where pages build once and refresh in the background on a timer or on demand
-   **Rendered per request behind the CDN,** which reads like a static site and publishes within a minute

Our Astro site uses the third. Blog routes set `prerender = false`, and a small helper sends `s-maxage=60` with `stale-while-revalidate=600`. The database gets hit about once a minute per page however much traffic arrives, and nobody waits for a query, since a visitor after the minute still gets the cached copy while the refresh runs behind them. Signed-in editors get `no-store` instead, so checking your own edit shows the edit.

Our Next.js sites use [ISR](https://nextjs.org/docs/app/guides/incremental-static-regeneration), with one trap worth its own paragraph. A blog post route with a revalidate value but no `generateStaticParams` renders every request dynamically, and our posts were taking up to 2.7 seconds to the first byte as a result. Add the function and the same pages serve from cache. While you're there, use a cookie-free database client for public reads, because the cookie-based server client breaks static generation and the sitemap at build time.

Whichever you pick, the sitemap is never cached. Split it in two: the marketing pages generated at build, and a blog sitemap that queries the database on every request. A post published a minute ago is already discoverable, and an unpublished one is already gone.

Remember what you gave up. Page budgets and Lighthouse CI read the prebuilt output, so per-request blog routes slip past both. Keep them light by hand and test them on a preview deployment.

## Step seven: build a signed publish endpoint

A site Claude Code builds has no admin panel, and an endpoint is how content gets in from outside: an AI writing tool, a CMS, a script. Ours is one route that accepts POST to create or update, PATCH to unpublish, and DELETE to remove. Any publishing tool that can sign a webhook can use it, Contengi included, and the contract below works with whichever you choose.

### Verify before you trust anything

Authenticate with an HMAC signature, never a bearer token in a header someone can copy. The sender signs `timestamp + "." + rawBody` with a shared secret, and the endpoint recomputes it over the exact bytes it received, before parsing the JSON. Compare in constant time, and reject timestamps more than five minutes old so a captured request can't be replayed later.

```
const rawBody = await request.text();
const signature = request.headers.get("x-signature") ?? "";
const timestamp = request.headers.get("x-timestamp") ?? "";

const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > 300) return fail("Stale request", 401);

const expected = "sha256=" +
  createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
if (!safeEqual(signature, expected)) return fail("Bad signature", 401);

const body = JSON.parse(rawBody); // only now
```

### The slug is the post's identity

This is the rule that saves you from duplicate pages. Publishing tools often send a new job ID every time an article is rewritten, so keying on that ID publishes a second post at the same address. Upsert on the slug instead, since the slug is the URL.

Updates get stricter handling. A request flagged as an update must replace an existing post and never insert. If the title changed and the payload carries a new slug, keep the slug that's already live, so a retitled rewrite can't move the post. If the endpoint can't find the post, fail loudly. A stale post left live beats a rewrite at a second URL competing with it.

### Copy images in, never hotlink

Publishing tools send image URLs that point at their storage. Save those as they are and your posts depend on someone else's bucket forever. Our endpoint downloads the featured image and every `img` in the body, checks the type and size of each one, and only then stores them in our own bucket under a path built from the slug and a hash of the source URL. It rewrites the post to the new addresses and drops `srcset`.

Download and validate everything first, then store. If any image fails, the publish is refused with a 502 and a message the person can act on, and nothing is saved. Orphaned half-publishes are worse than a clear error.

### Small things that make it pleasant

-   A `test: true` flag that replies 200 and stores nothing, so the sending tool can check the connection
-   Replies with the absolute live URL and the row's ID, so the sender can link straight to the post
-   Errors as JSON with a readable `error` field, which the sending tool shows to the person who pressed publish
-   Sanitised HTML on the way in, since bodies render as raw HTML and one stored script tag would sit in every page view
-   Unpublish sets the status back to draft and keeps the row, so republishing restores the same URL

```
Build a signed publish webhook at src/pages/api/publish.ts (prerender = false). Auth: X-Signature is sha256=hex(hmac_sha256(PUBLISH_WEBHOOK_SECRET, timestamp + "." + rawBody)) and X-Timestamp is unix seconds. Read the raw body, verify with a constant-time compare before parsing JSON, reject timestamps more than 300 seconds off. POST creates or updates; the slug is the identity, upsert on it. is_update: true must replace an existing post and never insert, keep the slug already live at published_url, and fail with a clear error if the post is not found. test: true replies 200 and stores nothing. Before saving, download the featured image and every body img, validate type and size, store them in our blog-images bucket under publisher/<slug>/<hash>.<ext>, rewrite the src and drop srcset; if any image fails, reply 502 and save nothing. Sanitise body_html. PATCH unpublishes (status draft, row kept), DELETE removes. Reply 200 { url, external_id } or non-2xx { error }. Write a test script that signs a request with the local secret.
```

## Step eight: give the humans an admin

An endpoint covers machines. People still want to fix a typo, swap a cover image, or unpublish something at 11pm without asking anyone. Our Astro site has a small CMS at an admin route with a posts list, a rich text editor, an image library, and a people screen.

Keep permissions in one place. Middleware guards everything under the admin and admin API paths, so adding an unprotected admin route requires putting it somewhere obviously wrong. Roles are simple: an owner who manages admins, admins who manage editors and delete posts, editors who write and publish. The UI hides buttons, the server enforces the rules.

Two traps here. If your editor is built on TipTap or any schema-based editor, it parses HTML into its own model and writes it back, silently dropping every tag it has no extension for. Open an article that came in through the endpoint, change the title, save, and the tables in the body vanish. Our editor tracks whether anyone touched the body and sends the stored HTML back byte for byte if not.

The second trap is email. Magic links feel modern, but Supabase's built-in mailer only delivers to members of the project, and a marketing site often has no mail provider at all. We create accounts with a starting password from the people screen and hand it over. Add a real mail provider before you add a magic link flow, never after.

## Step nine: migrate the old blog

Our biggest migration moved 262 posts off a hosted site builder, with 289 redirects. The builder had no export, so the posts came across the way a reader sees them, through a browser.

### Scrape deterministically

The importer runs headless Chrome over each live post and pulls the data out with fixed selectors and fixed rules. Dates, categories, and read time come from the page's JSON-LD. Title, description, and author come from the header block. Body sections are read in on-screen order and converted by type: text, image, embed, button. Anything else gets skipped and written to a report, so you can see exactly what didn't make it.

Body HTML passes through an allowlist of tags (paragraphs, headings, lists, links, emphasis, quotes, tables) and everything else gets stripped. Every image gets re-hosted into your own bucket under the post's slug, so the old site can go away without taking your pictures with it.

### Make re-runs safe

You'll run the import more than once. Every imported row carries a marker in its source column, and the update mode only replaces rows that carry it. Posts someone wrote by hand in the new CMS after the first import never get touched.

### One file for redirects

The SEO team owned the mapping, a two-column CSV of old path and new path, and that file is the single source of truth. The Astro config reads it at build time and generates the redirects. A page built at an old address wins over the mapping, because redirecting it would make the page unreachable. We found that rule the hard way, when the mapping sent a live service page to a blog post with a similar name.

### What the old site will throw at you

-   Content inside `srcdoc` iframes, which you read through the browser's frames API, never the main document
-   Embeds that only render when scrolled into view, so the scraper scrolls each one in and waits for its frame
-   Tables wrapped in `code` tags by the old builder, which need unwrapping
-   In-page anchors pointing at old section IDs, remapped to the new heading IDs
-   Images saved with the wrong extension, such as SVGs named .webp, which render broken everywhere until you re-encode them
-   FAQ sections where a question carries the answer to a different question, which a scraper will copy perfectly

```
Write a blog importer in scripts/blog-import using puppeteer-core and my installed Chrome. For each URL in the old sitemap's blog section: wait for the page to settle, read dates, categories and read time from JSON-LD, title and author from the header, and the body in on-screen order. Clean the body through an allowlist of tags (p, h2-h4, ul, ol, li, a, strong, em, br, blockquote, table and its children, img). Read srcdoc iframes through page.frames() and scroll lazy embeds into view first. Download every image, re-host it to our bucket under imported/<slug>/, and rewrite the src. Upsert into blog_posts as published with source_id = 'import'. --update may only replace rows carrying that marker. --only <slug> imports one post. Write a report of everything skipped. Keep the script out of the build and lint paths.
```

## Step ten: wire up SEO and AEO once

On a site builder, SEO settings live in a panel on every page. In code you set the pattern once and every page follows it. Our [Next.js SEO guide](https://contengi.com/blog/nextjs-seo) covers the basics line by line, so here's what we'd add from running three sites.

-   **One metadata helper for every page.** Next.js merges metadata shallowly, so pages without their own Open Graph block inherit the home page's URL. Our helper sets canonical, og:url, and Twitter fields on every route and clamps descriptions at 155 characters
-   **Fail the production build without a site URL.** Canonicals, the sitemap, and robots.txt all depend on it, and a missing value ships a sitemap full of localhost links
-   **Noindex every preview.** Generate robots.txt from the deployment environment, so previews say noindex and production says index
-   **Schema from the content.** BlogPosting and BreadcrumbList on every post, FAQPage built by parsing the FAQ section out of the body HTML, since publishing tools often put FAQs in the body and leave the structured field empty
-   **Machine-readable versions.** An `llms.txt`, an `llms-full.txt`, and a markdown copy of every page at its URL plus `.md`, linked with `rel="alternate" type="text/markdown"`, noindexed, and canonicalised to the HTML page
-   **A single facts file.** Pricing, product names, and company facts in one module that feeds pages, schema, and llms.txt, so the machines and the humans read the same numbers

Server rendering does most of the AEO work by itself. AI crawlers from OpenAI, Anthropic, and Perplexity read the HTML your server sends and skip JavaScript, so anything you want an assistant to know has to be in that HTML. We wrote more on this in [how to build an agent-friendly website](https://contengi.com/blog/how-to-build-an-agent-friendly-website), and before you block any bot, read our guide to [robots.txt for AI](https://contengi.com/blog/robots-txt-for-ai).

## Step eleven: make it fast and keep it fast

One rebuilt home page failed the 2.5 second LCP gate on mobile at first, with readings between 3.3 and 4.8 seconds. Two changes fixed it. We baked the hero's dark overlay into the photo itself, which removed a layer the browser had to paint, and we inlined the stylesheets into each page's head, because two render-blocking CSS requests were holding back first paint by over a second. The page now paints at 1.4 seconds and scores 100.

The other wins were less glamorous:

-   Load booking widgets and other heavy embeds on first interaction. Our calendar embed set third-party cookies and dropped best practices to 0.75 until it waited for a click
-   Self-host stock photos and video posters. Hotlinked images from a stock site set third-party cookies too
-   Lazy-load logos and diagrams below the fold with an intersection observer, since Lighthouse's LCP estimate counts every request that finished before the largest paint
-   Prefer the system monospace font over a downloaded one for code and labels
-   Lay scaled images out at their hover size and scale them down at rest, or they blur on hover

One warning about the CI numbers. Our Lighthouse CI config served the build folder's root, so pages were audited at the wrong path and every hashed asset returned a 404. CI looked fine while the real numbers were unknown. Check your CI audits the pages you think it does, and run Lighthouse locally against the real output before trusting a green tick.

## Step twelve: verify in a real browser

Every bug that reached our users passed a typecheck and a green build. Claude can drive a real Chrome through puppeteer-core, and asking it to prove a change in the browser catches what static checks can't see.

The lessons that changed how we write checks:

-   **Test the second interaction.** A popup's dismiss worked, a reload confirmed it, and the test passed. On mobile it reopened on the very next scroll, because the scroll listener stayed armed. Now every check dismisses and then keeps interacting
-   **Assert the real geometry.** The broken mobile menu passed because the panel had a height. The check should have asserted that its rectangle matched the viewport
-   **Test motion at human speed.** A hero animation passed because pixels changed over time, but a visible change took ten seconds. Assert a change a person would notice within a second
-   **Use real hover events.** React ignores a synthetic mouseenter, so dispatching one makes a working dropdown look broken
-   **Restart the dev server after switching branches.** A long-running dev server served stale CSS and made a healthy site look broken

```
Before calling this done, start the production build locally and prove it in Chrome with puppeteer-core at 1440px and 390px. For every changed page: no console errors, no horizontal scroll, exactly one h1, title and description present. For anything interactive, test the full sequence a person would follow, including what happens after they dismiss or close it. Report each check as pass or fail with the measured value, and show me any failure before fixing it.
```

## The ship loop for people who aren't developers

Everything above runs through three commands, which Claude calls on the person's behalf. `start-work` refuses to run with uncommitted changes, pulls the latest main, and creates a branch. `sync` previews a merge with main without touching any files and only merges if it's clean. `ready` runs every CI check locally, pushes, and prints the pull request link.

The person opens that link, checks the Vercel preview, and a reviewer merges. Claude never merges, never force-pushes, and never deletes a branch it didn't create. That's a lot less steps than it sounds once it's routine, and it's how a marketing team ships a dozen changes a week without ever touching the terminal themselves.

Two housekeeping notes. Vercel's Hobby plan is for [non-commercial use](https://vercel.com/docs/limits/fair-use-guidelines), so a business site belongs on Pro by launch day. And make sure everyone's git email matches their GitHub account, because Vercel can block deployments from commits it can't match to a team member.

## What this looks like after six months

The three sites now take changes daily from people who've never opened a terminal. Pages ship through pull requests in an afternoon, blog posts publish in under a minute, and the checks have stopped a home page that failed the LCP gate, a reference page that crept over its HTML budget, and a lockfile CI could not install from, all before anything reached production.

None of it is exotic. It's a rules file, four layers of checks, a design system with a reference page, a database with sensible policies, one well-behaved endpoint, and the habit of checking in a real browser. Claude Code does the typing. Your job is to decide the rules once and write them down where it'll read them.

If you'd rather not build the content side yourself, our guide to [CMS options for websites built with Claude](https://contengi.com/blog/top-cms-for-websites-built-with-claude) compares the alternatives.

## Frequently asked questions

### Can Claude Code build a full website?

Yes. Claude Code can build a production site end to end, including the design system, database, blog, admin, publishing endpoint, and CI. The quality depends on the rules you give it in CLAUDE.md and the checks you put in place, so set those up before the first page.

### Is Claude Code good for building websites?

It's very good at it, especially for marketing sites on Astro or Next.js. It writes clean components, follows a design system when one exists, and can verify its own work in a real browser. It needs a person to review changes and decide what ships.

### Do I need to know how to code to build a website with Claude Code?

You don't need to write code, but someone should be comfortable reading a pull request, checking a preview deployment, and running a migration in the Supabase SQL editor. Git hooks and CI checks make it safe for non-developers to run day to day.

### Should I use Astro or Next.js with Claude Code?

Use Astro for a site that's mostly pages and a blog, since it ships no JavaScript by default. Use Next.js when the site needs app features like logins, interactive tools, or generated PDFs. Both deploy to Vercel the same way.

### How much does it cost to run a website built with Claude Code?

The running costs are a Claude plan, Vercel hosting, and Supabase. Vercel's Hobby plan is for non-commercial use, so a business site needs Pro. Supabase's free tier works with a keepalive cron until you need paid features.

### How do I add a blog to a site Claude Code built?

Store posts in a database table with the slug as the post's identity, render them per request behind a CDN cache or with ISR, and add a signed publish endpoint so writing tools can post into it. A small admin covers manual edits.
