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:
| File | URL | Fetches | Renders |
|---|---|---|---|
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 | /blog | getBlogIndex() | head + PostGrid (auto-listed posts) + sectionsBelow[] |
blog/[slug].astro | /blog/<slug> | getPostBySlug(slug) | BlogPost + RelatedPosts |
portfolio/index.astro | /portfolio | getPortfolioIndex() | head + PostGrid (auto-listed portfolioPosts) + sectionsBelow[] |
portfolio/[slug].astro | /portfolio/<slug> | getPortfolioPostBySlug(slug) | PortfolioPost (its sections[] via SectionRenderer) |
free-website-audit.astro | /free-website-audit | siteSettings only | WebsiteAudit React island |
404.astro | (not found) | siteSettings only (best-effort) | static not-found copy |
sitemap.xml.ts | /sitemap.xml | published-only sitemap query | dynamic XML |
robots.txt.ts | /robots.txt | none | dynamic 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 SectionRenderer — clients (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:
- In Studio, create a
pagedocument with a unique slug and fill itssections[]. - 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, …) returnsnew Response(null, {status: 404}). The browser then falls through to404.astro. - 503 — Sanity unreachable. The fetch is wrapped in
try; a thrown error returns503withRetry-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
| Concern | File |
|---|---|
| Locked URL plan & route-file map | website/CLAUDE.md (§ "Routing (locked)") |
| Homepage route | website/src/pages/index.astro |
| Catch-all root pages | website/src/pages/[slug].astro |
| Service detail | website/src/pages/services/[slug].astro |
| Blog index / post | website/src/pages/blog/index.astro · website/src/pages/blog/[slug].astro |
| Portfolio index / post | website/src/pages/portfolio/index.astro · website/src/pages/portfolio/[slug].astro |
| Audit tool page | website/src/pages/free-website-audit.astro |
| Not-found page | website/src/pages/404.astro |
| Sitemap / robots | website/src/pages/sitemap.xml.ts · website/src/pages/robots.txt.ts |
| Reserved-slug validation | studio/schemaTypes/page.ts (RESERVED_SLUGS) |
| Per-request client selection | website/src/lib/sanity-draft.ts (getRouteClient) |
| Prefetch config | website/astro.config.mjs |