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.
Where the list lives
Section titled “Where the list lives”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, "…": "…" } } ]}discriminantis the section’s type.valueholds its fields. Which fields a type has is set insrc/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.
Reorder, remove, duplicate
Section titled “Reorder, remove, duplicate”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.
What to keep in mind
Section titled “What to keep in mind”- The opening holds the page’s main heading. In Handy, the
todosection’s title is the home page’s onlyh1. 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,jobsandjournalshow your latest entries (countsets how many);stepsshows the steps from the Prices page;areadraws 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,
counthas 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.
Add a new section type
Section titled “Add a new section type”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 whatdiscriminantwill say in the content. - The title is what the editor shows in the Add menu.
- The helpers at the top of the file (
longfor multi-line text,titlefor headlines that allow*stars*,linkfor 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.
2. Write the component
Section titled “2. Write the component”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-foldon anything that isn’t on the first screen, with a rough height incontain-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 underreduce. - 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.
3. Render it
Section titled “3. Render it”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} />; } })}4. Add it to the page and sync your CMS
Section titled “4. Add it to the page and sync your CMS”-
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 schemacd 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.
Remove a section type
Section titled “Remove a section type”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.
Related
Section titled “Related”- The content model: add a field to an existing section
- Add or remove a page