Building this site with Astro and Keystatic

· 4 min read

This site is live. Here's what I built, how, and why.

What I built

A personal site with three requirements:

  • Easy publishing: edit in a UI, commit to git, auto-deploy
  • Fast and minimal: no frontend frameworks, pages under 10KB
  • Clean URLs: posts at /post-title instead of /posts/post-title

Stack: Astro, Keystatic, Vercel.

Why Astro

Astro ships zero JavaScript by default. These pages are HTML and CSS—no React runtime, no hydration, no bundle to parse.

For a content site, this is exactly right. I need fast loads and good SEO, not interactivity. Astro's content collections give me type-safe frontmatter and easy querying with no runtime cost.

Why Keystatic

I needed a CMS that stores content in git, has a decent editing UI, works locally and in production, and doesn't require a separate backend.

Keystatic does all of this. Content lives in .mdoc files in the repo. I can edit locally in VS Code or use the admin UI at /keystatic. Changes go through git, so version history is built in.

Trade-off: the admin UI adds ~850KB to the /keystatic route. But that only loads when I'm editing—public pages don't touch it.

Project structure

src/
  content/
    posts/       # blog posts (mdoc)
    pages/       # about, now (mdoc)
    settings/    # site config (yaml)
  pages/
    [slug].astro # dynamic post routes
    writing.astro
    about.astro
    now.astro
  styles/
    global.css   # ~1.5KB gzipped

CSS is one file. No CSS-in-JS, no Tailwind, no extra build step. Custom properties for colors and spacing, system fonts. Dark mode via prefers-color-scheme.

Content types

Keystatic manages four content types:

Posts — title, description, published/updated dates, draft toggle, featured image, markdoc content. Draft posts are filtered from all public pages.

Pages — about and now pages, each with title, meta description, and markdoc content. The now page has a lastUpdated date that displays automatically.

Homepage — headline and intro text, editable separately from code.

Settings — site name, description, URL, social links. These populate meta tags across all pages.

Clean URLs

Posts render at /{slug} instead of /posts/{slug}. I moved the dynamic route from pages/posts/[slug].astro to pages/[slug].astro and added a reserved slugs check so posts can't collide with /about or /now.

const reserved = ['about', 'now', 'writing', 'keystatic', 'rss'];
const posts = (await getCollection('posts')).filter(p => !p.data.draft);
return posts
  .filter(post => !reserved.includes(post.slug))
  .map(post => ({ params: { slug: post.slug }, props: { post } }));

RSS and sitemap

RSS feed at /rss.xml, auto-generated from published posts. Sitemap at /sitemap-index.xml via @astrojs/sitemap. Both update on every build—no manual work.

The RSS feed link is in the HTML head, so feed readers can auto-discover it.

SEO

Every page gets:

  • JSON-LD structured dataArticle schema for posts, WebSite for pages
  • OpenGraph tags — title, description, URL, type
  • Twitter Card tags — summary card with title and description
  • Canonical URLs — prevents duplicate content issues
  • Article timestampsarticle:published_time and article:modified_time for posts

This makes the site readable by search engines and LLMs. No plugins—just meta tags generated in the layout component.

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': article ? 'Article' : 'WebSite',
  name: pageTitle,
  description,
  url: canonical.toString(),
  ...(article && {
    datePublished: article.published,
    dateModified: article.modified ?? article.published,
    author: { '@type': 'Person', name: siteName },
    publisher: { '@type': 'Organization', name: siteName }
  })
};

Accessibility

  • Semantic HTML<article>, <header>, <main>, <nav>, <time> elements
  • Active page indicatoraria-current="page" on the current nav link
  • System fonts — no layout shift from font loading
  • Keyboard navigation — focus-visible outlines on interactive elements
  • Color contrast — tested in both light and dark modes

Performance

  • CSS: 1.5KB gzipped
  • HTML per page: 3-5KB
  • Total page weight: under 10KB (excluding images)
  • No JavaScript on public pages
  • Lighthouse: 100/100 across performance, accessibility, best practices, SEO

Deployment

Push to main, Vercel builds and deploys. The site is statically generated at build time—no server functions for public pages. Keystatic Cloud handles authentication for the admin UI in production.

Build time is under 10 seconds.

What I'd do differently

Honestly, not much. The stack is simple and does what I need. If I were building something with more interactivity, I'd look at Astro's island architecture for selective hydration. But for a content site, zero JS is the right call.

Resources

The source is on GitHub if you want to see how it all fits together.

Back to writing