Pages & Routing
Twenty-five route files under src/pages/ produce 72 built pages, plus four generated text and XML endpoints. Seven of the files are dynamic, three are endpoints, and one builds nothing at all in production.
The map
| Route | File | Pages |
|---|---|---|
/ |
index.astro |
1 |
/product/ |
product.astro |
1 |
/pricing/ |
pricing.astro |
1 |
/about/ |
about.astro |
1 |
/contact/ |
contact.astro |
1 |
/blog/ |
blog/index.astro |
1 |
/blog/page/<n>/ |
blog/page/[page].astro |
1 |
/blog/<slug>/ |
blog/[slug].astro |
8 |
/blog/category/<slug>/ |
blog/category/[category].astro |
4 |
/customers/ |
customers/index.astro |
1 |
/customers/page/<n>/ |
customers/page/[page].astro |
1 |
/customers/<slug>/ |
customers/[slug].astro |
7 |
/customers/industry/<slug>/ |
customers/industry/[industry].astro |
5 |
/integrations/ |
integrations/index.astro |
1 |
/integrations/<slug>/ |
integrations/[slug].astro |
24 |
/integrations/category/<slug>/ |
integrations/category/[category].astro |
9 |
/signin/ |
signin.astro |
1 |
/signup/ |
signup.astro |
1 |
/privacy/ |
privacy.astro |
1 |
/terms/ |
terms.astro |
1 |
/404 |
404.astro |
1 |
/examples/<catalog> |
examples/[catalog].astro |
0 |
Plus four generated endpoints: robots.txt.ts, llms.txt.ts and rss.xml.ts are route files that prerender to static text at build, and sitemap-index.xml comes from @astrojs/sitemap. All four derive their absolute URLs from site, so setting that once fixes them together.
The dynamic page counts move with your content: eight posts, seven stories and 24 integrations are what ships, and the taxonomy routes build one page per label whether or not an entry claims it.
The thin route shell
Every .astro file in src/pages/ follows one contract. A page owns:
BaseLayout, and the title, description,imageandnoindexit passes to it;- any page-specific JSON-LD, via the
schemaandarticleprops; getStaticPathsfor a dynamic route;- which footer band the chrome should draw.
And it owns nothing else. Markup lives in sections, which never import BaseLayout. src/pages/integrations/index.astro is the clearest example: thirty lines of frontmatter, then six section tags.
Data flows in one of two ways, and never both for the same value: a section either receives typed props from the route, or reads typed config itself from @config/*. The integrations index shows the distinction deliberately — it destructures most of integrationData for its sections, but leaves the api block alone, because ApiBand reads that block itself (it already owns the request form’s state and anchor id, so handing it a copy would gain nothing).
Dynamic routes derive their props from getStaticPaths rather than re-reading the collection in the frontmatter:
export async function getStaticPaths() {
const posts = await publishedPosts();
return posts.map((post) => ({ params: { slug: post.id }, props: { post, posts } }));
}
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { post, posts } = Astro.props;
InferGetStaticPropsType is what removes the casts — the props are typed from the paths function that produced them.
Trailing slashes
trailingSlash: "always" in astro.config.mjs. Every internal href in config and markup ends in a slash, the directory build emits index.html per route, and canonical URLs, Open Graph og:url, the sitemap and the breadcrumb schema all agree on that one shape.
This is the sort of setting that costs nothing until it drifts, at which point you have two URLs for every page and a canonical tag pointing at the one nobody links to. If you change it, change the hrefs in navData, plannedRoutes and the *Data config files with it — navData.test.ts fails on a malformed href, which is the check that catches half of it.
The active nav state
Both the navbar and the mobile sheet mark the current page, and @js/nav derives it rather than each surface deciding for itself:
const current = navCurrent(Astro.url.pathname);
<NavLink href={href} aria-current={current.link(href)} />
<MegaMenuTrigger aria-current={current.section(entryHrefs(entry))} />
Two spellings, because a nav needs two ideas. aria-current="page" marks the link that is the route. aria-current="true" marks a menu trigger whose panel holds it — that section is current, but the button itself is not a page. Both are styled by the same aria-[current]: variants in ui/nav/NavLink.astro, which match the attribute rather than its value.
Redirects for designed-but-unbuilt routes
Seventeen URLs the design links have no page yet — the footer’s Product, Resources and Legal columns, four job listings, the trust centre. They are listed in src/config/plannedRoutes.json.ts and turned into redirects to /:
redirects: Object.fromEntries(plannedRoutes.map((route) => [route, "/"])),
This is not a catch-all. An unknown URL still gets the real 404 page. It covers exactly the routes the taxonomy promises and the build does not yet keep, so a live demo does not dead-end on its own footer.
Build the page, then delete its entry. A redirect and a real page at one route contradict each other, and plannedRoutes.test.ts fails the moment that happens.
The two routes that are not symmetric
/signin/ is noindex. A sign-in screen has nothing a search engine should rank — it is a door, not a destination — and indexing it competes with the pages that do sell the product while putting a login form in results.
/signup/ is deliberately indexable, because it is a real acquisition landing page with a value proposition on it, and it is where branded searches for the product plus “trial” should arrive. It is also the most heavily advertised route on the site: navData.cta.primary, every footer CTA band, three pricing tiers and two home-page CTAs all point at it. authData.test.ts asserts that CTA still lands there, so editing navData.cta to point elsewhere fails a check instead of quietly orphaning the page.
The sitemap’s filter in astro.config.mjs drops /examples/, /404/ and /signin/ — the three routes that set noindex in their markup — and that is deliberately one list, so “noindex” and “not in the sitemap” cannot drift apart. They did once, for exactly as long as it took to grep the built sitemap for the new route.
The dev-only catalog
/examples/<catalog> builds no pages in production:
export function getStaticPaths() {
return import.meta.env.PROD ? [] : [{ params: { catalog: "ui" } }];
}
astro dev still serves /examples/ui/. The page is also noindex and excluded from the sitemap, so the three statements about it agree even while it exists.
Its cost is not HTML — it is CSS, because Tailwind scans the catalog’s markup whether or not it builds a page. See Deployment for when to delete it.
Adding a page
- Create
src/pages/<name>.astro. - Import
BaseLayout, give it atitleand adescription— a unique one, since a description repeated across two routes is two URLs competing for one snippet. - Compose sections. If a block is new, put it in
Sections/<Page>/; if a second page later wants it, move it toSections/Global/. - Add its href to
navData.json.tsif it belongs in the chrome, and delete it fromplannedRoutes.json.tsif it was one of the seventeen.
That last step is the one that bites. pnpm test prints the unbuilt set on every run, and plannedRoutes.test.ts fails outright if a route is both redirected and built.