Architecture

Routing & pages

The website's URL plan is locked in code, but the content of most pages comes from Sanity — a single catch-all route ([slug].astro) serves every root-level page from a published page document, so adding /about or /contact is a content task, not a code change.

Each file under website/src/pages/ owns one URL shape. Singletons (homepage, blog index, portfolio index) have dedicated route files; collections (services, posts, portfolio posts, generic pages) use a single-segment dynamic route that fetches one document by its slug. Every content route picks its Sanity client through getRouteClient(Astro.cookies), fetches inside a try, and degrades deliberately — a missing document is a 404, an upstream Sanity outage is a 503 (never a 404, so crawlers don't de-index a real page during a blip).

See Querying content (GROQ) for the fetch helpers each route calls, and Page builder overview for how sections[] becomes rendered markup.

The locked URL plan

This is the canonical URL map for the site (from website/CLAUDE.md "Routing (locked)"):

/                  homepage
/services          services index
/services/[slug]   service detail
/work              case studies index
/work/[slug]       case study detail
/blog              blog index
/blog/[slug]       post
/about             about / founder
/contact           contact + lead form

/services, /about, and /contact are not their own route files — they're page documents served by the catch-all. /work and /work/[slug] are planned but not built (the caseStudy schema exists; the route + index page don't — see TODO.md).

The route files

These are the files that actually exist under website/src/pages/ today:

FileURLFetchesRenders
index.astro/getHomepage()SectionRenderer over homepage.sections[]
[slug].astro/<slug>getPageBySlug(slug)SectionRenderer over page.sections[]
services/[slug].astro/services/<slug>getServiceBySlug(slug)SectionRenderer over service.sections[]
blog/index.astro/bloggetBlogIndex()head + PostGrid (auto-listed posts) + sectionsBelow[]
blog/[slug].astro/blog/<slug>getPostBySlug(slug)BlogPost + RelatedPosts
portfolio/index.astro/portfoliogetPortfolioIndex()head + PostGrid (auto-listed portfolioPosts) + sectionsBelow[]
portfolio/[slug].astro/portfolio/<slug>getPortfolioPostBySlug(slug)PortfolioPost (its sections[] via SectionRenderer)
free-website-audit.astro/free-website-auditsiteSettings onlyWebsiteAudit React island
404.astro(not found)siteSettings only (best-effort)static not-found copy
sitemap.xml.ts/sitemap.xmlpublished-only sitemap querydynamic XML
robots.txt.ts/robots.txtnonedynamic robots.txt
api/audit.ts/api/audit(PageSpeed proxy)JSON — see Audit tool
api/draft-mode/enable.ts · disable.ts/api/draft-mode/*draft-cookie endpoints — see Presentation Tool & draft mode

/blog and /portfolio are singletons with their own files, not page docs — their index is a blogIndex / portfolioIndex document (heading + intro + grid variant + optional sectionsBelow[]), and the post list is auto-fetched by the GROQ query, never stored on the singleton.

The page-builder routes all hand the same fallback props to SectionRendererclients (logo-wall fallback), testimonials (testimonials fallback), caseStudies (work-section fallback). On /services/<slug> the caseStudies prop is the service's own relatedCaseStudies, so a workSection on a service page falls back to that service's picks:

<SectionRenderer
  sections={service.sections}
  clients={payload.clients}
  testimonials={payload.testimonials}
  caseStudies={service.relatedCaseStudies}
/>

Why [slug], not [...slug]

The root catch-all is pages/[slug].astro — a single-segment dynamic route, not the rest/spread form pages/[...slug].astro.

A rest route would shadow the nested paths

[...slug].astro matches any depth, so it would capture /blog/foo, /portfolio/bar, and /services/baz and try to serve them as page docs — shadowing the dedicated nested route files. Keeping it single-segment means [slug].astro only ever matches one path segment (/about, /process), and the nested folders (blog/, portfolio/, services/) win their own paths.

Adding a root-level page (content-only)

Adding a new top-level page like /about or /process needs no code change:

  1. In Studio, create a page document with a unique slug and fill its sections[].
  2. Publish it.

[slug].astro resolves the slug at request time (in the SSR/staging build) — it queries by Astro.params.slug, renders the doc's sections, and returns 404 if nothing matches:

const {slug} = Astro.params as {slug: string};
data = await getPageBySlug(slug, await getRouteClient(Astro.cookies));
if (!data?.page) {
  return new Response(null, {status: 404});
}

On the public site, publish ≠ live

"Publish a doc → instantly live, no rebuild" applies to staging (the SSR build that reads live per request). The public site is statically prerendered: a new page doc appears there only on the next manual deploy (Studio's "Deploy" tool / deploy hook) or a code push. That's why the dynamic routes export getStaticPaths() — it enumerates the published slugs to prerender. See Two-build split and Deployment.

getStaticPaths on the dynamic routes

The four dynamic routes ([slug], services/[slug], blog/[slug], portfolio/[slug]) each export getStaticPaths() to enumerate published slugs for the static build:

// Static (public) build: enumerate published page slugs at build time.
// Ignored in the server (staging) build, where slugs resolve per request.
export async function getStaticPaths() {
  const slugs = await getPageSlugs();
  return slugs.map(({slug}) => ({params: {slug}}));
}

In the server (staging) build these exports are ignored — Astro logs a harmless getStaticPaths() ignored WARN. That warning is expected, not a bug.

Reserved slugs — the escape hatch

Some root paths are owned by dedicated route files or API endpoints, so a page doc with one of those slugs would never be served (the file wins) or would shadow an API path. Studio blocks them at validation time. The list lives on the page schema in studio/schemaTypes/page.ts:

// Paths owned by dedicated route files in website/src/pages — a page doc with
// one of these slugs would never be served (the route file wins) or would
// shadow an API path. Keep in sync with website/src/pages/.
const RESERVED_SLUGS = ['blog', 'portfolio', 'free-website-audit', 'api', '404', 'sitemap.xml', 'robots.txt']

A slug validation rule rejects any of these with: "/<slug> is owned by a dedicated route in the website code — a page with this slug would never be served. Pick a different slug." Keep this array in sync whenever you add a dedicated route file.

One-off layouts: re-introduce a RESERVED_SLUGS redirect in the route

If you ever need a dedicated route file for a one-off layout (say an explicit pages/about.astro with bespoke markup), re-introduce a RESERVED_SLUGS guard at the top of [slug].astro so the catch-all doesn't shadow it. None exist today/services, /about, /contact are all served as page docs.


Error & outage handling

Every content route distinguishes a missing document from an upstream outage, because they map to different HTTP statuses:

  • 404 — document not found. After a successful fetch, if (!data?.page) (or .service, .post, …) returns new Response(null, {status: 404}). The browser then falls through to 404.astro.
  • 503 — Sanity unreachable. The fetch is wrapped in try; a thrown error returns 503 with Retry-After: 60. The comment on every route makes the intent explicit:
} catch (err) {
  // Upstream outage, not a missing page — don't serve 404 to crawlers.
  console.warn(`[/${slug}] page fetch failed:`, err);
  return new Response(null, {status: 503, headers: {'Retry-After': '60'}});
}

The homepage is the strictest case — its 503 exists specifically so a Sanity blip can't make Googlebot de-index /.

404.astro and free-website-audit.astro are the exceptions: they fetch only siteSettings for chrome, and degrade to empty settings rather than erroring, so the page still renders header/footer even when Sanity is down:

// Best-effort chrome: a 404 must render even when Sanity is unreachable,
// so this fetch degrades to empty settings instead of erroring.
let siteSettings: SiteSettings = {};
try { /* fetch */ } catch (err) {
  console.warn('[404] siteSettings fetch failed:', err);
}

Sitemap & robots

Both are dynamic SSR routes, not generated by @astrojs/sitemap — the SSR routing model means the integration can't enumerate the Sanity-driven URLs at build time, so it was removed (it produced a 4-URL file missing every Sanity page).

sitemap.xml.ts queries with the published client (drafts never belong in a sitemap) for every page, service, post, and portfolioPost slug plus the homepage and the two index singletons, emitting <loc> + <lastmod> per URL. robots.txt.ts serves Disallow: / on any *.workers.dev host or when PUBLIC_NOINDEX=true (the staging build), and Allow: / + the sitemap link on the canonical public build. See SEO & structured data.

Prefetch

The full-SSR site prefetches in-viewport links so the next page is already cached on click. From astro.config.mjs:

// Fully SSR site: prefetching in-viewport links lets the next page come
// from the browser/edge cache the instant a prospect clicks.
prefetch: {prefetchAll: true, defaultStrategy: 'viewport'},

Where this lives

ConcernFile
Locked URL plan & route-file mapwebsite/CLAUDE.md (§ "Routing (locked)")
Homepage routewebsite/src/pages/index.astro
Catch-all root pageswebsite/src/pages/[slug].astro
Service detailwebsite/src/pages/services/[slug].astro
Blog index / postwebsite/src/pages/blog/index.astro · website/src/pages/blog/[slug].astro
Portfolio index / postwebsite/src/pages/portfolio/index.astro · website/src/pages/portfolio/[slug].astro
Audit tool pagewebsite/src/pages/free-website-audit.astro
Not-found pagewebsite/src/pages/404.astro
Sitemap / robotswebsite/src/pages/sitemap.xml.ts · website/src/pages/robots.txt.ts
Reserved-slug validationstudio/schemaTypes/page.ts (RESERVED_SLUGS)
Per-request client selectionwebsite/src/lib/sanity-draft.ts (getRouteClient)
Prefetch configwebsite/astro.config.mjs
Previous
Two-build split