skip to content
lav.ren / writing / 2026-04-rebuilding-lav-ren-in-astro
Published
2026.04.14
essay

Rebuilding lav.ren in Astro (and why)

Notes from rebuilding a four-year-old Bootstrap 4 site as Astro 5 + React islands. Zero JS on the static pages, one clever hydration pattern I almost got wrong, no regrets.

The old lav.ren was a Bootstrap 4 static site I threw together as a student in 2021 — FontAwesome, jQuery, Masonry grid, the full turn-of-the-decade stack. It served its purpose for four years, but I want somewhere to write about what I build, and the old site was missing a blog, a place for interactive experiments, and an honest case study for helldivers.bot. Rebuilding it also gave me an excuse to do what every engineer secretly wants — stress-test a shiny framework with an actual deliverable.

Why Astro and not Next.js

I picked Astro 5 for this site for the same reason I picked Next.js for helldivers.bot: use the framework that fits the job. helldivers.bot is a real-time PWA with a database, auth, push notifications, and a service worker — it needs the full React platform, streaming, server components, the works. lav.ren is 90% static content (about, projects, writing) with 10% interactive islands (demos that run in the browser). Astro ships zero JavaScript by default for the static pages and only hydrates the components I mark as islands. The home page weighs essentially nothing. The experiments page carries exactly the JS it needs.

If you squint, the split looks like two different tools for the same job — but the honest framing is that “modern React framework” has become an umbrella term for a dozen different tradeoffs. Astro optimizes for content-first sites; Next.js optimizes for interactive apps. They pick different defaults and reward different workflows.

Content Collections with MDX

Every project, blog post, and experiment lives as an MDX file in src/content/ with a Zod-validated frontmatter schema. I get:

  • Typo errors at build time instead of at render time. Forget a date field on a blog post? The build fails. Pick a tag that’s not in the allowed set? Build fails.
  • Colocated images that get optimized automatically. Drop a hero image next to the MDX file, import it with ./hero.jpg, and Astro generates AVIF + WebP at multiple sizes with content hashing and LQIP placeholders. A 7.9 MB WebP I started with became 25–273 KB per breakpoint.
  • React islands in prose. Because MDX parses JSX, I can drop an interactive component inside a project case study or blog post. A future post about sorting algorithms can literally embed a visualizer next to the paragraph that explains the algorithm.

The alternative was a headless CMS. I considered it for ten seconds. Then I remembered I live in git, not in a CMS dashboard, and the additional moving parts would earn their keep only if I had editors other than me.

The one pattern I almost got wrong

For the experiments section, my first instinct was to put the React component name in the MDX frontmatter as a string and import() it dynamically:

// Don't do this.
const Island = await import(`../islands/${frontmatter.island}.jsx`);

A colleague (okay, an adversarial debate agent I ran my plan through) pointed out that Vite can’t statically analyze that expression. The bundler either ships all islands on every experiment page (defeating code-splitting) or falls back to a runtime fetch that 404s on typos with zero warning.

The fix is a boring static map:

// src/components/astro/ExperimentIsland.astro
import AoCSolver from '~/components/react/experiments/AoCSolver.jsx';

const registry = {
  'advent-of-code': AoCSolver,
};
const Component = registry[Astro.props.id];

Combined with a Zod .enum() validation of the island field in the content schema, a typo in the frontmatter becomes a build error instead of a production 404. Vite now analyzes each static import separately and code-splits each island into its own chunk that loads only when the experiment scrolls into view. Same feature, no footguns.

What’s next

Most of the work beyond v1 is content, not code: more project case studies, the first few blog posts, more experiments. On the engineering side, the things I’ll come back to are the homepage composition (currently a placeholder — I’m letting the content shape it), the 3D section (it deserves more than a gallery), and the deploy story (Kamal-driven Docker images to the VPS, triggered by GitHub Actions on push to main).

If you’re reading this, you’ve seen it. Thanks for clicking around.