Pages & Routing
Routes live in src/pages/ and are deliberately thin. A route owns its layout wrapper, its SEO, and any data lookup those two need. It composes sections and holds no markup of its own. Everything visible lives in src/components/Sections/.
The build emits 28 HTML pages plus four generated text endpoints, all static.
Every route
| Route | File | Notes |
|---|---|---|
/ |
index.astro |
Hero, Why, Process, Benefits, Testimonials, Pricing, FAQ, CTA |
/features/ |
features.astro |
Hero, three alternating feature rows, audience cards, stack, pricing, CTA |
/pricing/ |
pricing.astro |
Plans, comparison matrix, pricing FAQ, CTA |
/about/ |
about.astro |
Page hero, two story blocks, values, team, CTA |
/contact/ |
contact.astro |
Page hero, contact board, contact FAQ — no CTA, by design |
/blog/ |
blog/index.astro |
Featured card, filter pills, post grid |
/blog/<id>/ |
blog/[id].astro |
7 pages — hero, TOC + article, related, CTA |
/integrations/ |
integrations/index.astro |
Search, featured row, filterable grid |
/integrations/<id>/ |
integrations/[id].astro |
9 pages — hero, highlight, setup, CTA |
/signin/ |
signin.astro |
Chrome-free auth shell, noindex |
/signup/ |
signup.astro |
Chrome-free auth shell, noindex |
/terms/ |
terms.astro |
Renders legalData.terms |
/privacy/ |
privacy.astro |
Renders legalData.privacy |
/404/ |
404.astro |
noindex, served for any unmatched route |
/examples/ui/ |
examples/[catalog].astro |
Dev-only; emits no paths in a production build |
/rss.xml |
rss.xml.ts |
Hand-built RSS 2.0 |
/robots.txt |
robots.txt.ts |
Dynamic endpoint, prerendered |
/llms.txt |
llms.txt.ts |
Dynamic endpoint, prerendered |
/sitemap-index.xml |
@astrojs/sitemap |
Filtered — see below |
Trailing slashes are enforced by trailingSlash: "always" in astro.config.mjs, which agrees with the directory build, the canonical tags, the RSS item links and every internal href in navData.
What a thin route owns
Read src/pages/index.astro — it is thirty lines, eleven of them imports:
---
import Hero from "@components/Sections/Home/Hero.astro";
// …the other sections…
import { faqsFor } from "@config/faqData.json";
import siteData from "@config/siteData.json";
import BaseLayout from "@layouts/BaseLayout.astro";
---
<BaseLayout title={siteData.title} description={siteData.description}>
<Navbar slot="header" />
<Hero />
<Why />
<Process />
<Benefits />
<Testimonials />
<Pricing />
<Faq items={faqsFor("home")} />
<Cta />
<Footer slot="footer" />
</BaseLayout>
A route owns four things and nothing else:
BaseLayoutwith a real title and description. Never a placeholder — these are the<title>, the meta description, the OG title and the Twitter card.- SEO extras where they apply:
noindex,image,schema(page-specific JSON-LD nodes), andarticle(which flipsog:typeand emits thearticle:*meta). - Data lookup the SEO tags need.
blog/[id].astroresolves the author because the JSON-LD needs the name; that is a route concern. - Which sections to compose, in what order.
A route holds no markup. If you find yourself writing a <div> in a page file, that markup belongs in a section.
The header and footer slots
BaseLayout is chrome-free by default. Page chrome is opt-in through two named slots:
<Navbar slot="header" />
<Footer slot="footer" />
The reason is landmark correctness. Rendering them into the slots lands <header> and <footer> as siblings of <main>, giving you a real banner and a real contentinfo landmark. Putting them inside the default slot would nest both inside <main>, which is wrong for assistive technology and passes every visual check.
The two auth pages omit both slots entirely — that is what “chrome-free by design” means for /signin/ and /signup/.
The navbar’s one prop
Navbar takes onDark, defaulting to true. At rest the bar is transparent with white text, because most pages open on a dark hero panel. Pages whose hero is light pass onDark={false} so the resting text is the theme foreground instead — /pricing/, /blog/<id>/ and both integrations routes do.
Past a small scroll threshold the bar condenses into a blurred pill in the page background colour, which is theme-aware in both modes. The scrolled state and the no-JS state are safe on any background, so onDark only affects the at-rest appearance.
Dynamic routes
Both dynamic routes follow the same shape: read the collection through its helper module, map entries to params and props.
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}
Passing the entry through props rather than re-fetching it by id inside the page is the pattern to copy — it is one lookup instead of two, and the prop is typed.
Note the param is id, matching the collection entry id, and the file is [id].astro. Astro 5’s collection API dropped slug in favour of id; keeping the param name aligned with the field avoids a translation step nobody needs.
The dev-only catalog route
src/pages/examples/[catalog].astro gates itself with import.meta.env.PROD inside getStaticPaths:
export function getStaticPaths() {
return import.meta.env.PROD ? [] : [{ params: { catalog: "ui" } }];
}
No paths in production means no HTML ships. astro dev still serves /examples/ui/. This is the pattern for any route you want available while building but absent from the deployed site — it needs no adapter and no runtime guard.
noindex and the sitemap
Four routes set noindex in their markup: /404/, /examples/ui/, /signin/ and /signup/. The auth pages are noindexed because an auth form has no search value, and their panel copy repeats the homepage hero verbatim — indexing them would hand a search engine a duplicate of the H1 that actually ranks.
The sitemap integration carries a matching filter in astro.config.mjs:
sitemap({
filter: (page) => !["/examples/", "/404/", "/signin/", "/signup/"].some((p) => page.includes(p)),
});
Keep the two in step. A page that sets noindex but appears in the sitemap is telling a crawler two different things. If you add a noindexed route, add it to the filter in the same commit.
The pages stay crawlable — robots.txt allows everything — so the directive is actually seen rather than hidden behind a Disallow.
Adding a page
- Create the route at
src/pages/<name>.astrowithBaseLayout, a real title and description, and the two chrome slots if the page should have them. - Create its sections under
src/components/Sections/<Name>/. If a block you need already exists inGlobal/, use it —PageHero,Faq,Cta,SectionHeader,IconCardsandPricingare all designed to be reused, and every shipped page except the home page reuses at least one. - Add it to
navData.header. The header entries are the site’s route inventory, and the file states the convention: adding a page to the site means adding a line here. Skip it and your page is unreachable except by URL. - Add a sitemap filter entry if the page is
noindex. - Run
pnpm build. It is the check that catches a broken import, a bad content reference or a route that accidentally depends on request-time behaviour.
If your page needs a section that only differs from an existing one by a flag or two, prefer the flag — Story is rendered twice on /about/ with a reverse prop, and Faq takes a different subset per page. If the layout tree genuinely diverges, write a sibling section instead. That is the same call the Cards folder documents for FeaturedPostCard versus BlogCard.
Making one route server-rendered
The theme has no adapter, so every route is static. Adding export const prerender = false; to a page without installing an adapter fails the build with ActionsWithoutServerOutputError rather than silently degrading — which is the intended guardrail.
The one place this is set up and waiting is src/pages/contact.astro, where the line ships commented out. See Contact Form for the four steps, and note that turning that route on-demand leaves the other 27 static.