Skip to content
AstroCraft Docs
On this theme

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:

  1. BaseLayout, and the SEO props it forwards — title, description, image, noindex, schema, article.
  2. Data resolution: await getCases(), await getPosts(), getStaticPaths.
  3. The composition order of its sections.
  4. 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 href in navData.json.ts carries a trailing slash. A row written without one will not match the built route.
  • Canonical and og:url agree on that shape, because both derive from new URL(Astro.url.pathname, Astro.site) in BaseHead. Never reconstruct a URL by hand elsewhere.
  • /rss.xml is the exception. It is a file, not a directory route, so it carries no slash. Same for /robots.txt and /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 the services array

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

  1. Create the file in src/pages/. The path is the URL.
  2. Create src/components/Sections/<Page>/ for its sections.
  3. Give BaseLayout a unique title and description. Those are the two tags that still matter, so pull them from typed config or content frontmatter rather than a site-wide default.
  4. If the page needs a nav row, add it to navData.json.ts with a trailing slash — the active state then follows from the URL with nothing else to set.
  5. If it is dynamic, use satisfies GetStaticPaths and pass resolved data through props.
  6. If it should not be indexed, set noindex and 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.

NEXT STEPColors & Theming