Skip to content
AstroCraft Docs
On this theme

Pages & Routing

Twenty-four page routes under src/pages/ produce 123 pages, plus three generated text and XML endpoints. Nine of the page routes are dynamic, one builds nothing at all in production, and exactly one is served on demand rather than emitted as a file.

The map

Route file URL Pages
index.astro / 1
product/index.astro /product/ 1
product/integrations/index.astro /product/integrations/ 1
product/integrations/[slug].astro /product/integrations/<slug>/ 60
pricing.astro /pricing/ 1
customers/index.astro /customers/ 1
customers/[slug].astro /customers/<slug>/ 10
customers/industry/[industry].astro /customers/industry/<slug>/ 5
blog/index.astro /blog/ 1
blog/page/[page].astro /blog/page/<n>/ 1
blog/[slug].astro /blog/<slug>/ 13
blog/category/[category].astro /blog/category/<slug>/ 5
blog/author/[author].astro /blog/author/<slug>/ 5
careers/index.astro /careers/ 1
careers/[slug].astro /careers/<slug>/ 9
about.astro /about/ 1
contact.astro /contact/ 1 — on demand
login.astro, signup.astro, forgot-password.astro the three account screens 3
privacy.astro, terms.astro the legal pages 2
404.astro /404.html 1
examples/[catalog].astro /examples/ui/ 0 in production

Plus three endpoint files — robots.txt.ts, llms.txt.ts and rss.xml.ts — and sitemap-index.xml / sitemap-0.xml from @astrojs/sitemap.

122 HTML files land in dist/client/; /contact/ is served by the Worker. Of the 123, 119 are indexable — the 404 and the three account screens set noindex and are filtered out of the sitemap.

The thin route shell

Every route file in the theme is between five and fifty lines, and src/pages/product/index.astro is the shape in miniature:

---
import Product from "@components/Product/Product.astro";
---

<Product />

A route owns three things and delegates everything else: its URL, its getStaticPaths where it has one, and the resolution of whatever entries the page needs. The markup, the layout and the SEO props belong to a feature component under src/components/<Feature>/.

The two content detail routes take that a step further, and the reason is worth knowing if you write a third. blog/[slug].astro and customers/[slug].astro both call render() themselves:

const { Content, headings } = await render(post);

render() returns two things that are needed in different places — the <Content /> component for the body and the headings array for the reading rail — so the route resolves the pair and passes both down. That keeps Post.astro and Story.astro free of astro:content calls entirely, which is what makes them ordinary components rather than route-shaped ones.

Trailing slashes

astro.config.mjs sets trailingSlash: "always", and everything agrees with it: the directory build, getLocalizedRoute, the canonical link, hreflang, og:url and every href in navData.

One exception exists, and it is a file rather than a directory: /rss.xml. getLocalizedRoute leaves it un-slashed for that reason. This is not a hypothetical edge — the helper was normalizing it into /rss.xml/, which 404s under trailingSlash: "always", and the link-integrity check is what caught it.

The one on-demand route

/contact/ sets prerender = false so its form can receive a POST:

export const prerender = false;

output stays static, so Astro prerenders everything else. 122 pages remain HTML files on a CDN; one route runs on the adapter.

Because that route emits no file, two things that read the build output would each be wrong about it — in opposite directions. @astrojs/sitemap enumerates built pages, so it would silently drop an indexable page. The link-integrity hook builds its “what exists” set from the output directory, so it would call all 561 links pointing at /contact/ dead.

One array in astro.config.mjs feeds both:

const ON_DEMAND_ROUTES = ["/contact/"];

The sitemap names it through customPages, and the link check seeds its emitted set with it. Adding an on-demand route is one edit.

There is no planned-routes list here and no redirect table, because there is nothing to redirect. The footer was trimmed to the pages that exist rather than stubbed out — nine rows that used to 404 were deleted or retargeted — and a build hook keeps it that way.

finly:link-integrity runs on astro:build:done. It walks the emitted HTML, collects every href="/…", and compares it against the set of paths the build actually produced:

1 link(s) in the built HTML point at a path this build did not produce:
  /product/bill-pay/  — drawn by 4 page(s), e.g. /product/index.html
Build the page, retarget the link, or drop it — every link this site draws resolves.

Two design decisions make it complete rather than approximate.

It reads the output, not navData. The first version compared config rows against the page list, which is narrower than the defect in four ways it could not see: the headerCta button on 120 of 123 pages, every footer row, a linkHref written in a customer story’s frontmatter, and a href that a helper rewrote on the way out. Scanning what actually shipped covers all four.

The “what exists” set comes from the output directory, not from the page list, so endpoints and assets count as destinations too. /rss.xml, /robots.txt, /llms.txt and /favicon.svg are real targets the page list never mentions.

The obvious alternative — a test deriving routes from filenames — cannot be exact. A walker has to treat any directory holding a [param] file as a wildcard prefix, which waves through /customers/bogus/ and /careers/fake-role/: precisely the failure the check exists to catch.

The dev-only catalog route

src/pages/examples/[catalog].astro serves /examples/ui/ in development and builds nothing in production:

export function getStaticPaths() {
  return import.meta.env.PROD ? [] : [{ params: { catalog: "ui" } }];
}

It also sets noindex, and the sitemap filter excludes /examples/ — belt and braces for a route that emits no HTML anyway.

Leave it in place while you build. When you are done, delete src/pages/examples/ and src/components/UiCatalog/ together: Tailwind still scans the catalog’s markup even though it builds no pages, so its demo classes sit in the stylesheet every real page loads until the components go too.

Adding a route

  1. Create the file under src/pages/. Keep it thin.
  2. Create the feature component it delegates to, under src/components/<Feature>/.
  3. Put its copy in a typed config module under src/config/en/, with an interface beside it in src/config/types/.
  4. Give it structured data. The SEO rule is explicit that a new page type brings its own JSON-LD node — use a builder from @js/schema and pass it via the schema prop.
  5. Add it to the curated crawl surfaces: one line in llms.txt.ts, and a filter exclusion in the sitemap if it is noindex.
  6. Link it from navData — and note the ordering. Add the route before the row, or pnpm build will fail on the dead link, which is the check doing its job.
NEXT STEPColors & Theming