Skip to content

Home page sections

The home page is a list of sections. Each one has a type (an opening, services, recent jobs, a closing call…) and its own fields. You change the list in the content editor; you add a new type in the code.

src/content/singletons/en/home.json (in Keystatic: Pages → Home page) holds the page’s SEO fields and its sections, in the order they appear:

{
"seoTitle": "Sorted Handyman Services · Your handyman in Millbridge",
"seoDescription": "Flat-pack, shelves and TVs, small plumbing jobs…",
"sections": [
{
"discriminant": "todo",
"value": { "eyebrow": "…", "title": "Your to-do list, *sorted*", "…": "…" }
},
{
"discriminant": "services",
"value": { "eyebrow": "What I do", "title": "Odd jobs, *done properly*", "count": 6, "…": "…" }
}
]
}
  • discriminant is the section’s type.
  • value holds its fields. Which fields a type has is set in src/content/model.ts.

In a site with more languages, each language has its own home.json (src/content/singletons/de/home.json…), so each can have its own order.

Handy’s section types:

Type Section Component
todo Your to-do list: the opening, a home fixed room by room as you scroll Todo.astro
services Services on pegboard hooks Services.astro
jobs Recent jobs, before and after Jobs.astro
steps How a job works, on a strip of tape Steps.astro
numbers Numbers on a tape measure Numbers.astro
area The area covered (the map) Area.astro
journal Latest tips Journal.astro
newsletter Tips by email sign-up Newsletter.astro
cta Closing call Cta.astro

Every type is one component in src/components/sections/. Other templates have their own types; your template’s page in these docs lists them.

In Keystatic (Pages → Home page):

  • Reorder: drag a section by the handle on its left.
  • Remove: the bin icon on the section.
  • Add or duplicate: Add, pick the type, fill in its fields. For a copy of an existing section, add one of the same type and fill it in the same way.

Click Save.

In the file: move, delete or copy whole { "discriminant": …, "value": … } objects inside sections. Keep the commas between them right; the build tells you if the file isn’t valid.

In Sanity or EmDash: the home page has the same list of sections in the Studio or the EmDash admin, with the same types and fields.

  • The opening holds the page’s main heading. In Handy, the todo section’s title is the home page’s only h1. Keep exactly one opening section, at the top. Without it, the page has no main heading (search engines and screen readers expect one).
  • One of each is the safe default. Several sections link their heading by a fixed id (for example services-title, cta-title). Two sections of the same type on one page repeat that id, which the Lighthouse accessibility check reports. If you want a type twice, give its heading an id that’s unique per section in the component.
  • Some sections read other content. services, jobs and journal show your latest entries (count sets how many); steps shows the steps from the Prices page; area draws the map with the radius from the Contact page. Edit those in their own place.
  • Rules are checked. The to-do list must have exactly five items, count has a minimum and a maximum, and so on. The editor shows the rule; the build stops with the field’s path if the file breaks one.

Say you want a “Testimonial” section: a quote, who said it, and where. Four steps, all in your project.

1. Describe its fields in the content model

Section titled “1. Describe its fields in the content model”

In src/content/model.ts, sectionSchema lists every type with block(type, title, fields). Add yours to the list, for example after numbers:

block('testimonial', 'A testimonial (a quote from a customer)', {
quote: z.string().meta(long),
name: z.string().meta({ title: 'Who said it' }),
place: z.string().default('').meta({ description: 'e.g. "Elm Park". Optional.' }),
}),
  • The type ('testimonial') is what discriminant will say in the content.
  • The title is what the editor shows in the Add menu.
  • The helpers at the top of the file (long for multi-line text, title for headlines that allow *stars*, link for a label and an address, image() for a photo) work here too. A photo field needs a folder: image().meta({ title: 'Photo', image: { directory: 'src/assets/photos/home' } }).
  • Optional text: use .default(''), as above, so every CMS stores the field the same way.

Create src/components/sections/Testimonial.astro. Its props are the section’s fields, typed straight from the model through src/data/sections.ts:

---
import type { HomeSection } from '../../data/sections';
type Props = Extract<HomeSection, { discriminant: 'testimonial' }>['value'];
/** A quote from a customer. */
const { quote, name, place } = Astro.props;
---
<figure class="below-fold mx-auto mt-24 max-w-[44rem] [contain-intrinsic-size:auto_320px]">
<blockquote class="font-display text-[clamp(1.8rem,4vw,2.8rem)] leading-tight text-text">“{quote}”</blockquote>
<figcaption class="mt-4 text-muted">{name}{place && `, ${place}`}</figcaption>
</figure>

Follow the rules the other sections follow:

  • below-fold on anything that isn’t on the first screen, with a rough height in contain-intrinsic-size, so the browser can skip it until it’s scrolled near.
  • Colors only through the theme’s classes (text-text, text-muted, bg-panel, text-link…), never a hex value. Both themes then work, with readable contrast.
  • All visible text comes from the fields. If you need a fixed word such as “Read more”, add a field for it (or put it in Interface texts) rather than writing it in the component.
  • Animations go inside @media (prefers-reduced-motion: no-preference) or stop under reduce.
  • Photos go through <Photo> (see Working in the code).

src/data/sections.ts itself needs no change: HomeSection is read from the model, so the new type is there as soon as step 1 is saved.

In src/components/Sections.astro, import the component and add one case:

---
import Testimonial from './sections/Testimonial.astro';
// …the other imports
---
{
sections.map((s) => {
switch (s.discriminant) {
// …the other cases
case 'testimonial':
return <Testimonial {...s.value} />;
}
})
}
  • Keystatic: nothing to do. The editor is generated from the model: open Pages → Home page, Add, and the new type is in the menu. Fill it in, save, and it’s on the page.

  • Sanity: regenerate the Studio’s schema and deploy the Studio, then add the section there (see Sanity):

    Terminal window
    npm run cms -- sanity schema
    cd studio && npm run deploy
  • EmDash: bring the EmDash admin in line with the model (it creates the new block type and keeps your content), with an API token that has schema:write (EmDash):

    Terminal window
    EMDASH_URL=https://your-site.com EMDASH_TOKEN=ec_pat_… npm run emdash:schema

Then run npm run build: if a section in the content is missing a required field, the build stops and names it. A type without a case in Sections.astro doesn’t stop the build, it just renders nothing, so look at the page too.

To drop a type for good: remove it from the content first (every language), then its block(…) in model.ts, its case and import in Sections.astro, and its component. With Sanity or EmDash, sync the schema as above.