Pages & Routing
Sixteen route files under src/pages/ produce 29 built pages. Four of them are dynamic, three are text endpoints, and one builds nothing at all in production.
The routes
| File | Builds |
|---|---|
index.astro |
/ |
about.astro |
/about/ |
services.astro |
/services/ |
contact.astro |
/contact/ |
work/index.astro |
/work/ |
work/[slug].astro |
/work/<slug>/ × 6 |
blog/[...page].astro |
/blog/, /blog/2/ |
blog/[slug].astro |
/blog/<slug>/ × 8 |
blog/category/[category]/[...page].astro |
/blog/category/<slug>/ × 5 |
terms.astro |
/terms/ |
privacy.astro |
/privacy/ |
404.astro |
/404.html |
examples/[catalog].astro |
nothing in production; /examples/ui/ in dev |
robots.txt.ts |
/robots.txt |
llms.txt.ts |
/llms.txt |
rss.xml.ts |
/rss.xml |
Plus /sitemap-index.xml and /sitemap-0.xml from @astrojs/sitemap.
Every route is a thin shell
This is the contract, and it is the single most useful thing to internalise about the theme. A route owns four things and nothing else:
BaseLayout, and the SEO props it forwards —title,description,image,noindex,schema,article.- Data resolution:
await getCases(),await getPosts(),getStaticPaths. - The composition order of its sections.
- Site chrome, through
BaseLayout’s named slots.
It contains almost no markup. The home page is 46 lines, and the majority of those are a comment explaining why the sections appear in that order.
---
import Header from "@components/Sections/Global/Header.astro";
import Footer from "@components/Sections/Global/Footer.astro";
import WorkGrid from "@components/Sections/Work/WorkGrid.astro";
import WorkHero from "@components/Sections/Work/WorkHero.astro";
import { getCases } from "@js/work";
import BaseLayout from "@layouts/BaseLayout.astro";
const cases = await getCases();
---
<BaseLayout title={`Work — ${siteData.name}`} description="…">
<Header slot="header" />
<WorkHero />
<WorkGrid cases={cases} />
<CallToAction />
<Footer slot="footer" />
</BaseLayout>
Chrome goes through named slots
<Header slot="header" /> and <Footer slot="footer" />, never the default slot. BaseLayout places the default slot inside <main> and the two named slots outside it:
<body class="min-h-[100lvh]">
<slot name="header" />
<main><slot /></main>
<slot name="footer" />
</body>
That placement is the whole point. A <footer> nested inside <main> is not exposed as the contentinfo landmark, and a <header> inside it is not banner. Passing chrome through the default slot silently costs you two landmarks.
Data flows down, config flows in
A section either receives its content as typed props from the route, or reads config for itself — never both for the same data. Collection content is always the route’s job, because getStaticPaths already has the list in hand and a section that queried for itself would re-query once per page. Config is the section’s own to read: Header imports navData directly.
/work/<slug>/ shows why the split matters. relatedCases needs the whole collection and the current entry — the one thing a section cannot read for itself without re-querying — so it is resolved in getStaticPaths and rides along as a prop:
export const getStaticPaths = (async () => {
const cases = await getCases();
return cases.map((entry) => ({
params: { slug: entry.id },
props: { entry, related: relatedCases(cases, entry) },
}));
}) satisfies GetStaticPaths;
Note satisfies GetStaticPaths rather than a cast. It means paginate()’s props flow through into Astro.props with real types — the category route reads const { page, category } = Astro.props with category already typed as a BlogCategory, no assertion needed.
The active nav state is derived, never set
No page sets an active flag. isCurrentPath in src/js/nav.ts answers the question from the URL:
export function isCurrentPath(pathname: string, href: string): boolean {
if (pathname === href) return true;
if (href === "/") return false;
return pathname.startsWith(href.endsWith("/") ? href : `${href}/`);
}
It is a prefix match, so a section link stays lit on its children — /work/meridian/ lights up the Work row, and /blog/category/engineering/ lights up Blog. Two edges it handles explicitly:
/is an ancestor of everything, so prefix-matching Home would light it on every page. It only ever matches exactly.- The prefix is normalized to end in
/, so it can only land on a whole path segment./workshop/does not start with/work/.
The result drives aria-current="page", which is what the Nav and NavHighlight primitives style off. The platform models active state; the theme just answers the question.
nav.ts is deliberately dependency-free, and that is not incidental. nav.test.ts runs under bare Node with type stripping, which does not resolve the @config/* path aliases — so a single config import there would fail the whole self-check with a module-resolution error instead of a useful assertion. Anything that needs to read config lives beside that config, which is why headerHref is in navData.json.ts.
Trailing slashes
astro.config.mjs sets trailingSlash: "always", and with the default directory build every route emits path/index.html. Three consequences:
- Every
hrefinnavData.json.tscarries a trailing slash. A row written without one will not match the built route. - Canonical and
og:urlagree on that shape, because both derive fromnew URL(Astro.url.pathname, Astro.site)inBaseHead. Never reconstruct a URL by hand elsewhere. /rss.xmlis the exception. It is a file, not a directory route, so it carries no slash. Same for/robots.txtand/llms.txt.
This was tightened from "ignore" after the optional CMS was removed — "ignore" was only ever needed because a CMS’s extensionless API calls 404’d under "always". If you add one that needs them, loosening it back is the fix.
noindex is a prop
<BaseLayout title="…" description="…" noindex={true}>
Two routes set it: 404.astro and examples/[catalog].astro. It flips the robots meta tag. A noindex page must also be excluded from the sitemap, or the two disagree — which is why astro.config.mjs carries a filter:
sitemap({ filter: (page) => !page.includes("/examples/") && !page.includes("/404/") })
If you add a noindexed route, add it to that filter in the same commit.
The dev-only catalog
examples/[catalog].astro gates itself inside getStaticPaths:
export function getStaticPaths() {
return import.meta.env.PROD ? [] : [{ params: { catalog: "ui" } }];
}
A production build emits no paths, so no HTML ships. astro dev still serves /examples/ui/. This replaced an earlier on-demand 404 guard that required a server adapter — the static gate needs none, which is what lets the theme stay adapter-free.
The eight routes that do not exist
navData.json.ts ships the design’s complete information architecture so the header and footer look finished. Eight of its targets have no route file:
/careers/and/process/— in the footer’s Company column- Six
/services/<slug>/detail pages — derived from theservicesarray
They are documented in the config file, in the README and in Deployment step 6, and they 404 until you build or delete them. Building one is a normal page: a route shell, a folder under Sections/, and the section files. Deleting one is a line in navData.
Adding a route
- Create the file in
src/pages/. The path is the URL. - Create
src/components/Sections/<Page>/for its sections. - Give
BaseLayouta uniquetitleanddescription. Those are the two tags that still matter, so pull them from typed config or content frontmatter rather than a site-wide default. - If the page needs a nav row, add it to
navData.json.tswith a trailing slash — the active state then follows from the URL with nothing else to set. - If it is dynamic, use
satisfies GetStaticPathsand pass resolved data throughprops. - If it should not be indexed, set
noindexand add it to the sitemap filter.
A note on internationalisation
Develi is single-language, deliberately. The former i18n layer — the locale helper modules, per-locale config and data registries, hreflang in BaseHead and the i18n block in astro.config.mjs — was removed as a unit. The only locale facts left are siteLang and siteLocale in siteSettings.json.ts.
If you need multiple languages, restore those pieces together rather than adding locale plumbing piecemeal: a route prefix without hreflang alternates, or per-locale content without a language filter on the collection queries, produces a site that looks translated and reads as duplicate content to a crawler. The collection loaders would also want nesting (blog/<locale>/<slug>/, giving <locale>/<slug> ids) and a language filter in getPosts.