Skip to content
AstroCraft Docs
On this theme

Pages & Routing

TVfolio’s route table is small and every entry is deliberate. trailingSlash: "always" is set in astro.config.mjs, so canonical URLs, the sitemap and every internal link agree on one shape — and a route referenced without its trailing slash is a 404 rather than a redirect.

The routes

Route Source Built from
/ pages/index.astro Sections/Home/Hero
/work/, /work/<slug>/ pages/work/index.astro, [slug].astro Sections/Work/*, the work collection
/blog/, /blog/<slug>/ pages/blog/index.astro, [slug].astro Sections/Blog/*, the blog collection
/about/ pages/about.astro Sections/About/*, Global/*
/cv/ pages/cv.astro config/cvData.json.ts
/contact/ pages/contact.astro Sections/Contact/ContactForm.astro
/privacy/, /terms/ pages/privacy.astro, terms.astro config/legalData.json.ts
/404 pages/404.astro Global/TestCard, Global/TransmissionList
/examples/ui pages/examples/[catalog].astro Sections/UiCatalog/* — dev only

A production build emits 23 HTML pages: nine fixed routes, six project pages, eight bulletins. Nine of those routes draw the television; the two legal pages are the exception, rendering as documents rather than inside the tube.

Generated endpoints

Six more routes are code rather than pages, and all of them prerender to static files:

Endpoint Source Notes
/robots.txt robots.txt.ts its Sitemap: line resolves against site
/llms.txt llms.txt.ts a curated entry-point map, not a second sitemap
/rss.xml rss.xml.ts RSS 2.0, built by @js/rss
/cv.txt cv.txt.ts the CV as plain text
/cv.pdf cv.pdf.ts the CV as a one-page PDF
/sitemap-index.xml @astrojs/sitemap 22 URLs

They are endpoints rather than files in public/ for one shared reason: every absolute URL inside them derives from site, so setting your domain once fixes all of them together. A hand-maintained robots.txt naming the wrong host is the exact failure this avoids.

The sitemap filter drops two things:

sitemap({ filter: (page) => !page.includes("/examples/") && !page.includes("/404/") })

Both are marked noindex in the markup, so a sitemap entry for either would contradict the page itself.

The one on-demand route

POST /api/contact/ sets prerender = false and is the only route in the site that runs code at request time. It is also the only reason an adapter is installed. Contact Form & Email covers it in full, and Deployment covers what it does to the shape of dist/.

A GET on that path redirects to /contact/ rather than returning a blank 404 — someone typing the URL gets the form.

The page shell pattern

Every route follows the same shape, and it is worth seeing once because the repetition is the point:

---
const page = "Work";
const entries = await getWork();
---

<BaseLayout title={`Work — ${siteData.name}`} description={/* derived */}>
  <TvSet>
    <TvScreenBar slot="screen-top" channel={channelFor(page)} label={page} />
    <FastextBar slot="screen-bottom" current={page} />

    <div class="flex flex-col gap-[max(20px,calc(20*var(--tv-u)))]">
      <TransmissionGrid {entries} />
      <ContactRow />
    </div>
  </TvSet>
</BaseLayout>

The page’s name is declared once, as page. That one string tunes the channel bar, presses its fastext key and lights its row in the PAGES index — because channelFor(page) looks the number up rather than the route typing "02". If navData has no channel with that label, the build throws.

TvSet takes content in three places: the default slot is the picture, and the two named slots — screen-top and screen-bottom — are broadcast chrome pinned to the tube rather than scrolling with the content. A route that supplies neither gets a plain tube; the viewport’s insets are driven by :has(), so it only reserves the room actually used.

The 404, which is the most interesting route

404.astro is worth reading in full. The design’s answer to a dead channel is not an apology — it is a tuning aid: colour bars, then the full channel lineup, so a reader who lands there sees everything the set can reach.

What makes it notable is that the lineup is derived end to end. The channel numbers, names and order come from navData.channels. The counts come from the collections:

const [work, bulletins] = await Promise.all([getWork(), getBulletins()]);

The page’s own map adds only what navData does not own — an editorial descriptor, and for the two channels deliberately left href-less, the route this page can supply. So a 404 promising “06 TRANSMISSIONS” beside seven of them is impossible: the number is read off the collection.

The assertChannelLabels helper is what keeps that map honest. A label naming no channel would silently drop its row — indistinguishable from the deliberate omissions the lineup relies on, since AUDIO and EPISODE are left out on purpose. The assertion separates the two: what is present must be real, what is absent is a choice. It throws at build time.

One detail worth stealing: work.at(0) rather than work[0]. .at() is typed T | undefined, which [0] is not while noUncheckedIndexedAccess is off — so an empty collection becomes a narrowing the compiler enforces rather than a TypeError a buyer discovers by emptying src/data/work/.

The 404 is noindex, has no href anywhere in navData, and is served by the host for whatever it could not resolve. On Cloudflare that is not_found_handling: "404-page" in wrangler.jsonc.

The dev-only catalog

/examples/ui renders every UI primitive in every variant. Its guard is four lines:

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

Empty paths in a production build means no HTML ships. It is also noindex and excluded from the sitemap, so it is covered three ways.

The important caveat: that guard stops the pages, not the assets. The demo primitives’ styles and bundled scripts still reach the shared stylesheet and dist/_astro/, and the measured cost is real — 24 JS chunks with the catalog against 6 without, and a 22.6% larger shared stylesheet. Deployment has the full table. Delete the catalog before launch.

Adding a route

Three steps, and the second is the one people forget:

  1. Create the page in src/pages/, following the shell pattern above.
  2. Add its channel to navData.channels with an href — in the same commit. A page with no channel makes channelFor throw; a channel with an href to a route that does not exist is a 404.
  3. If it deserves a coloured key, add it to fastextKeys too. There are six keys and they are physical buttons on a real remote, so adding a seventh is a design decision rather than a config edit.

If the page should appear in llms.txt, add a line there — but only if it is a section, not an entry. That file is deliberately an editorial map of entry points; individual bulletins and transmissions are absent because /blog/ and /work/ already lead a crawler to all of them and the sitemap enumerates them.

NEXT STEPThe TV Set