Skip to content
All work

11 min readclient work, next.js, sanity

ZŠ a MŠ Malenice

Shipped

Next.js and Sanity behind a live school site with exactly one non-technical editor.

  • Next.js
  • Sanity
  • TypeScript
  • Vercel

Every village school in Czechia has roughly the same website: Joomla, installed around 2013, with URLs like /index.php/2013-06-15-12-37-29/, a PDF of the school rules from 2018, and a phone number that is two teachers out of date.

I rebuilt one. ZŠ a MŠ Malenice is a small school in South Bohemia — five grades in three classrooms, plus a single-class kindergarten. The site is live, and it has exactly one editor: the headmaster. Not a developer, and with no spare time to become one.

That single fact shaped every technical decision. The site has to be editable between lessons, and it has to be impossible to break from the inside.

Homepage of skolamalenice.cz

Image 1 of 3: Homepage of skolamalenice.cz


The Stack

LayerChoiceWhy
FrameworkNext.js 16 (App Router, Turbopack)Server components, typed routes, ISR via cache tags
UIReact 19 + Tailwind CSS 4Design tokens live in @theme, no component library
CMSSanity 6 + next-sanity 13Embedded Studio, Czech localization, typed GROQ
LanguageTypeScript (strict)Schema-to-render type safety via codegen
HostingVercelZero-ops for a client who has no ops

No auth system, no database, no API layer of my own. Content is Sanity's job, rendering is Next's job, and there is nothing in between for anyone to maintain.


The Central Decision: What Lives in Code, What Lives in the CMS

The tempting move with a headless CMS is to build a page builder — sections, blocks, drag and drop, "the client can do anything". For a one-person school that would have been a trap. Give a non-technical editor control over layout and you eventually get a page with three heroes, an orphaned page nothing links to, and a magenta heading on a yellow band at 2.9:1 contrast.

So the split is deliberate and enforced by the code:

In codeIn Sanity
Page layouts, section order, navigationNews, events, photo galleries
Colors, fonts, components, band themesStaff, downloadable documents
The accessibility statement (legally binding)Body text of content pages (named fields)
Route structureContacts, official data, service notices, photos

Every content page has a fixed template in code and pulls named fields from a Sanity singleton — pageZs, pageMs, pageDruzina and so on. Each field has a fallback to the original copy, so an emptied field degrades to the previous text instead of leaving a hole.

A new page means a new schema plus a new route — a deliberate act by a developer. That is the point, not a limitation.


Sanity Studio, in Czech, at /studio

The Studio is mounted inside the Next app at /studio and runs in Czech via @sanity/locale-cs-cz. The convention across all schemas: labels and help text in Czech, identifiers in English.

defineField({
  name: 'slug',
  title: 'Adresa článku',
  type: 'slug',
  description:
    'Vygeneruje se z titulku. Po zveřejnění ji raději neměňte — staré odkazy by přestaly fungovat.',
  options: { source: 'titulek', maxLength: 96 },
  validation: (rule) => rule.required(),
})

That description is where most of the "ease of administration" actually lives. Not in a manual nobody reads — in the field, at the moment of the decision. Every non-obvious field has one: why not to change a published slug, how long a teaser should be, why the image needs alt text.

The desk structure is flat and ordered by frequency of use, not by data model elegance:

S.list()
  .title('Obsah webu')
  .items([
    S.documentTypeListItem('post').title('Aktuality').icon(Newspaper),
    S.documentTypeListItem('event').title('Události').icon(CalendarDays),
    S.documentTypeListItem('gallery').title('Fotogalerie').icon(Images),
    S.divider(),
    S.documentTypeListItem('person').title('Zaměstnanci').icon(User),
    S.documentTypeListItem('download').title('Dokumenty').icon(FileText),
    S.divider(),
    S.listItem().title('Stránky').icon(BookOpen).child(/* singletons */),
    S.divider(),
    S.documentListItem()
      .id('siteSettings')
      .title('Nastavení webu')
      .icon(Settings),
  ])

Things added weekly are on top. Page texts are one level down. Site settings are at the bottom, where you go twice a year.

Publishing a news post is: click Aktuality, click +, fill five fields, hit publish. The date is pre-filled with today, the category defaults to school announcements, the slug generates itself. The rich text editor offers headings, lists, links, images with captions, file attachments, callouts and quotes — and no font sizes or colors, on purpose, so every article looks the same five years from now.


Typed From Schema to Rendered Page

Every GROQ query is declared with defineQuery in a single file, which lets Sanity's codegen see it:

pnpm typegen   # sanity schema extract --force && sanity typegen generate

The generated sanity.types.ts is committed, so a schema change that breaks a component fails at typecheck instead of in production. 25 queries, all in one place, all typed against the actual schema.

Next's typedRoutes covers the other half: internal links are type-checked, so a dead link is a build error. On a site whose entire job is helping a parent find the school rules, a 404 is a real defect.


Publishing: Webhook → revalidateTag

Published content is served from cache and invalidated by tag. A Sanity webhook hits an API route that revalidates only what changed:

const { isValidSignature, body } = await parseBody(
  request,
  process.env.SANITY_REVALIDATE_SECRET,
)
if (!isValidSignature)
  return new NextResponse('Neplatný podpis požadavku.', { status: 401 })
 
revalidateTag(body._type, 'max')
if (body.slug?.current)
  revalidateTag(`${body._type}:${body.slug.current}`, 'max')

Publishing one news post does not rebuild the site; it drops the post tag and that article's tag. In practice the change is visible in under a minute. Drafts are previewable in the Studio through Presentation with a read token, so the headmaster sees the finished page before anyone else does.


The CMS Is Allowed to Fail

Components never touch the Sanity client directly. Everything goes through one wrapper:

export async function nactiData<T>({
  query,
  params,
  fallback,
}: FetchOptions<T>): Promise<T> {
  try {
    const { data } = await sanityFetch({ query, ...(params ? { params } : {}) })
    return (data as T) ?? fallback
  } catch (error) {
    console.error('Načtení dat ze Sanity selhalo:', error)
    return fallback
  }
}

Every call site passes a fallback. If Sanity is down or misconfigured, the news list renders empty and the rest of the page — address, phone numbers, opening hours, school rules — still loads. For a public institution, a stale page beats an error page every time.

There is exactly one documented exception: generateStaticParams and sitemap run at build time, where there is no request, so they read through the client directly. sanityFetch asks for draftMode() internally and Next rejects that outside a request — which silently produced an empty list of slugs and skipped pre-rendering entirely. That is the kind of failure worth a comment in the code.


One Source of Truth for Phone Numbers

Contacts are the thing that rots fastest on a school site. So phone numbers and e-mails have exactly two homes: the official list in siteSettings, and personal contacts on the person documents. They are never typed into free text.

Section pages (primary school, kindergarten, after-school club, canteen) have a kontaktniOsoby field that holds references to person — not copied values. An empty field means "everyone in the matching group", so a section page can never end up with no contact at all.

This rule exists because it was broken once: the canteen number lived in a paragraph of body text as well as in the contacts, the two drifted apart, and parents got the wrong one.

Documents work the same way. A download has a sekce array, so the annual report can belong to both the primary school and the kindergarten and appear on both pages from one upload, queried with $sekce in sekce. Categories, sections and staff groups are enumerated once in lib/taxonomy.ts and shared by the schemas and the site, so the dropdown in the Studio and the headings on the page can't disagree.


Accessibility Is Not a Nice-to-Have Here

A Czech school is a public body under Act 99/2019, so WCAG 2.1 AA is a legal requirement, not a preference. The brand manual, meanwhile, was written for print — and print does not care about contrast ratios.

So the palette is measured, not eyeballed, and the measurements are in the repo:

  • School green on cream: 7.79:1 → default text color everywhere
  • Raspberry on cream: 3.61:1 → large text and surfaces only; small text uses a darkened variant
  • Raspberry on yellow: 2.96:1 → forbidden combination, and the codebase says so

Three scripts keep it honest:

  • pnpm contrast — recomputes every color pair in the palette against WCAG thresholds
  • pnpm check:classes — guards a Tailwind trap explained below
  • pnpm check:layout — drives headless Chrome over five viewport widths and looks for overflow and overlap in the header

The Tailwind one is my favorite bug of the project. Custom font sizes named text-vetsi look exactly like text colors named text-zelena to tailwind-merge, which therefore drops one of them as a duplicate. The result was a button with cream text on a cream background. The fix is a script that fails the build if a custom size isn't registered in the list cn() knows about — a five-minute check that replaced a whole class of invisible regressions.

Animations are CSS-only: scroll-driven animations behind @supports, disabled under prefers-reduced-motion. No animation library was added, which also means nothing to upgrade in three years.


Getting Off Joomla

Two pieces of migration work that are easy to underestimate.

Old URLs. Joomla's menu IDs encode the date the menu item was created, not its content, so /index.php/2013-06-15-12-38-04/ is the kindergarten only if you recognize it. I mapped the old addresses by hand against the original page titles into a permanent redirect table in next.config.ts; anything unmapped lands on a 404 page with real navigation instead of a dead end.

Seeding. A script imports the page texts, staff, events and 13 documents recovered from the old site, with fixed _ids so re-runs are idempotent. It refuses to run against a non-empty dataset — after handover, a re-run would overwrite the headmaster's edits and resurrect what he deleted.

And one Sanity gotcha worth knowing: a document _id must not contain a dot. Sanity uses it as a version separator (drafts., versions.). An _id like download.skolni-rad writes fine and shows up in the Studio, but GROQ won't return it — not in the raw perspective, not via path(). That is how 23 invisible documents sat in the dataset after the first import, discoverable only through reference checks on delete.


Handover

The deliverable isn't the deploy, it's the handover:

  • A written guide for the editor in the repo: log in, publish a post, upload a document, post a temporary notice, use the preview
  • Service notices with a valid-until date, so a "school closed tomorrow" banner can't hang there for six months
  • Photos uploaded by the school into the CMS; an empty photo field renders a colored surface instead of a broken layout
  • robots.txt blocking indexing entirely until the site runs on the real domain, so the staging URL never reaches Google
  • Vercel project transfer, when the school takes ownership — no code or env changes

The Takeaway

About 9,000 lines across 105 files, and 61 of the 68 commits landed inside one week — built CLI-first with agents, the way I described in my CLI-first AI workflow. The reason it went that fast isn't the model; it's that the architectural rules were written down first. What's in code, what's in the CMS, where phone numbers live, which color pairs are illegal. Constraints like that are what let you generate a lot of code without generating a lot of mess.

Next.js plus Sanity is close to the ideal shape for this class of project: a small public institution, one non-technical editor, content that must stay correct for years, and an accessibility standard that is actually the law. The school gets a browser login. Nobody gets a server to patch.