Skip to content
nullchefo
← Back to journal
2 min read

Rebuilding nullchefo.com with Astro

Why I traded a React SPA for islands, content collections and zero JavaScript by default — and what the migration actually looked like.

#astro#typescript#meta Also available in: Български

The previous version of this site was a Next.js app. It worked, but it shipped a full React runtime to render what is, honestly, a document: some text about me, a list of jobs, a few links. This rewrite is built on Astro, and the result is plain HTML and CSS with a few hundred bytes of vanilla JS for the mobile menu and scroll reveals.

Why Astro

Three reasons made the decision easy:

  • Zero JS by default. Components render to HTML at build time. JavaScript only exists where I explicitly add it.
  • Content collections. Blog posts are MDX files validated by a schema at build time — a typo in a date is a build error, not a silent bug in production.
  • It’s just TypeScript. Data files, helpers and templates share one type system. My CV data is a typed object consumed by both the English and Bulgarian pages.

Content as files

Every post on this site lives in a folder named after its language:

src/content/blog/
├── en/
│   └── rebuilding-nullchefo-with-astro.mdx
└── bg/
    └── rebuilding-nullchefo-with-astro.mdx

The folder name is the locale. Adding a German version of this post would mean creating de/rebuilding-nullchefo-with-astro.mdx — nothing else. The collection schema keeps everyone honest:

const blog = defineCollection({
	loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),
	schema: z.object({
		title: z.string(),
		description: z.string(),
		pubDate: z.coerce.date(),
		tags: z.array(z.string()).default([]),
		draft: z.boolean().default(false),
	}),
});

Because translations share a file name, the post page can discover its own siblings and link to them — the “also available in” line above is generated from the file system, not maintained by hand.

What I kept from the old site

The data. The old portfolio had its CV content in TypeScript files, which turned out to be the most durable part of the codebase. It moved over almost unchanged, just with stricter types and ISO dates so each locale can format periods its own way:

const monthYear = new Intl.DateTimeFormat(localeTag, {
	month: 'short',
	year: 'numeric',
});

Rewrites rarely pay off because of the framework. They pay off when they force you to separate what changes often (content) from what doesn’t (rendering).

The numbers

MetricNext.js SPAAstro
JS shipped (homepage)~140 KB< 2 KB
Lighthouse perf80s100
Build outputserverstatic

Static output means the whole site deploys to any host that can serve files — no Node server, no cold starts, nothing to patch at 2 AM.

Stefan Kehayov

Full-stack engineer · Dospat · Bulgaria