Skip to content

Add or remove a page

There are two kinds of new page: one you can make from content alone, in the editor, and one that needs a new route in the code because it has a layout of its own.

Some pages exist for every entry of a collection, so adding an entry adds a page:

In Handy Adds a page at Edit it in
A service /services/<slug> Content → Services
A job /jobs/<slug> Content → Jobs
A tip /journal/<slug> Content → Tips
A legal page /legal/<slug> Pages → Legal

The slug (the end of the address) comes from the entry’s file name, which Keystatic makes from its name or title.

Legal is the general-purpose one: a title, a “last updated” date and a Markdown body (headings, lists, links), shown as a plain readable article. Every template has it, and it’s fine for any simple text page, such as an accessibility statement or a cookie policy. Add the entry, then link it: Site settings → Footer links or Navigation.

These pages appear by themselves in /sitemap.xml and /llms.txt, in every language. Services, jobs and tips are also in the site search and the AI chat’s knowledge.

Other templates have their own collections with pages (bouquets, walks, classes, team…): your template’s page lists them. Aurora also has Landing pages, built from sections like the home page and published at /<slug>.

For a page that isn’t an entry of a collection, for example an About page with a heading, a photo and a few paragraphs, you add its content, a view and two small route files. The example uses Handy; in other templates the files have the same names and roles.

In src/content/model.ts, add the page next to the other single pages (servicesPage, jobsPage…). heading (eyebrow, title, lead) and seo (SEO title and description) are helpers defined at the top of the file:

/** About page: heading, SEO, a photo and the story. */
aboutPage: z
.object({
...heading,
...seo,
story: z.string().meta({ title: 'Story', multiline: true }),
photo: image().meta({ title: 'Photo', description: 'A 3:2 photo.', image: { directory: 'src/assets/photos/about' } }),
photoAlt: z.string().meta({ title: 'Photo description (alt text)' }),
})
.meta({ title: 'About page' }),

Three files list which content exists; add the page to each.

src/content/cms.ts: add 'aboutPage' to the key type of ContentEntry, and a line to CONTENT (this is what puts it in the editor’s sidebar):

{ key: 'aboutPage', kind: 'singleton', label: 'About page', group: 'Pages' },

src/content.config.ts: add it to collections:

aboutPage: defineCollection({ loader: loaderFor('aboutPage'), schema: ({ image }) => m(image).aboutPage }),

src/lib/content/source.ts: add 'aboutPage' to the Single type, load it in load() next to the others and return it in pages:

const [site, ui, home, services, jobs, prices, journal, contact, notFound, about] = await Promise.all([
// …the other pages
single('notFoundPage', locale, fallbacks),
single('aboutPage', locale, fallbacks),
]);
// …
return { locale, langOf, site, ui, home, pages: { services, jobs, prices, journal, contact, notFound, about } };

Now every page can read it as Astro.locals.content.pages.about. In the EmDash variant, source.ts reads from EmDash instead of files but has the same Single type and load(): make the same change there.

Create src/content/singletons/en/aboutPage.json with every required field, or restart npm run dev and fill it in under Pages → About page in Keystatic. The build stops if the file is missing a field, and says which. With more languages, add the file in each language’s folder; until you do, the page shows the default language’s text.

Views in src/views/ are the pages themselves. Create src/views/About.astro:

---
import PageHeader from '../components/PageHeader.astro';
import Photo from '../components/Photo.astro';
import Base from '../layouts/Base.astro';
/** About: who we are, with a photo. */
const { pages, langOf } = Astro.locals.content;
const page = pages.about;
---
<Base title={page.seoTitle} description={page.seoDescription}>
<div lang={langOf('aboutPage')}>
<PageHeader eyebrow={page.eyebrow} title={page.title} lead={page.lead} />
<div class="mt-14 grid items-start gap-10 lg:grid-cols-2">
<Photo src={page.photo} alt={page.photoAlt} widths={[480, 720, 960]} sizes="(min-width: 64rem) 45vw, calc(100vw - 2rem)" loading="eager" fetchpriority="high" class="rounded-brand" />
<p class="max-w-[60ch] text-lg leading-relaxed whitespace-pre-line">{page.story}</p>
</div>
</div>
</Base>

<Base> gives the page its head (title, description, canonical link, social preview, language links), header, footer, phone tab bar, chat and search. PageHeader renders the eyebrow, the h1 (with *stars* marking words, as on the other pages) and the lead. langOf marks the text with the default language when a translation is missing.

Each page has two route files of a few lines. The first serves the default language at the root, src/pages/about.astro → /about:

---
import About from '../views/About.astro';
---
<About />

The second serves the other languages, src/pages/[locale]/about.astro → /de/about:

---
import { exists, otherLocales } from '../../lib/routes';
import About from '../../views/About.astro';
export const getStaticPaths = otherLocales;
if (!(await exists(Astro.params))) return Astro.rewrite('/404');
---
<About />

With one language the second file builds nothing, but keep it: adding a language later then just works.

  • Site settings → Navigation: add About → /about. It shows in the header, the footer and the phone menu. Write links without a language prefix (/about, not /de/about): they’re turned into the visitor’s language by themselves.
  • On phones the tab bar shows four tabs (Interface texts → Navigation → Phone tab bar); every navigation link that isn’t a tab is in its Menu. Swap a tab only if the page matters that much.

These are built from lists in the code, so a new route needs a line in each:

  • src/pages/sitemap.xml.ts: add '/about' to paths. It’s then listed in every language, with its translations linked.

  • src/pages/llms.txt.ts: add link(pages.about.seoTitle, '/about', pages.about.seoDescription), next to the other pages.

  • src/lib/search-pages.ts (optional): add a record so the site search finds it:

    { url: '/about', title: pages.about.seoTitle, section: pages.about.seoTitle, markdown: pages.about.story },
  • src/lib/chat-knowledge.ts (optional) holds what the AI chat knows about the site; add the page there if visitors might ask about it.

  • Keystatic: nothing more; the page is in the sidebar from step 2.
  • Sanity: npm run cms -- sanity schema, deploy the Studio (cd studio && npm run deploy), then create and publish the About page there.
  • EmDash: npm run emdash:schema (see EmDash), then fill the page in the admin.

Then npm run build to check.

Say you don’t show finished jobs. The simplest removal keeps the content model as it is and removes the page and every way to reach it:

  1. Delete the route files: src/pages/jobs/ and src/pages/[locale]/jobs/.
  2. Delete the views they used, src/views/Jobs.astro and src/views/Job.astro.
  3. Remove the links in the content: Site settings → Navigation, the phone tab bar in Interface texts (replace the tab with another page: it has exactly four), and any button that points at /jobs.
  4. Remove the home page sections that show it (the jobs section; see Home page sections).
  5. Remove its lines from src/pages/sitemap.xml.ts, src/pages/llms.txt.ts, src/lib/search-pages.ts and src/lib/chat-knowledge.ts.
  6. Search the project for the path (/jobs) to catch the rest. In Handy, job cards (JobCard.astro) link to a job’s page, and they also appear in the “Recent jobs” part of each service’s page (src/views/Service.astro): remove that part too.
  7. npm run build, then click through the site. The build doesn’t check links, so a forgotten one leads to the 404 page.

Removing the content type itself as well (the Jobs collection in the editor) means undoing step 2 of “A new page” for it: its entry in cms.ts, content.config.ts and model.ts, and everything that reads it. Leave that for last, and only if the empty entry in the editor bothers you.