Every technology choice is a trade-off. When we set out to build the Keeny site, we had a clear constraint: ship fast, keep it maintainable, and make it feel like we know what we are doing. The stack had to get out of the way and let us focus on content and craft.

Here is what we picked and why.

Static by Default

We chose Astro over Next.js, Remix, and every other full-stack framework. The reason is simple: this is a content site. It does not need client-side routing, global state management, or hydration of an entire React tree just to display text and images.

Astro ships zero JavaScript by default. Every page renders to static HTML at build time. When you visit our landing page, your browser receives clean markup and CSS — no framework runtime, no hydration waterfall, no bundle to parse before you can read the first word.

This is not a philosophical stance. It is a performance decision. Our pages load in under a second on 3G connections. Google Lighthouse scores stay in the high 90s without heroic optimization efforts. The mental model is simpler too — most of our code is HTML templates with data, not a state management puzzle.

Astro still supports interactive islands when you need them. A contact form can use client:load to hydrate a single component while the rest of the page stays static. You opt into JavaScript per-component, not per-page.

---
// This component renders to static HTML. Zero JS shipped.
const services = await getCollection("services");
---
<ul>
  {services.map((s) => <li>{s.data.title}</li>)}
</ul>

We evaluated Next.js seriously. It is a great framework for applications — dashboards, SaaS products, anything with authentication flows and real-time data. But for a landing page and blog, it ships more JavaScript than we need, adds server-side complexity we do not want, and optimizes for a use case we do not have.

Tailwind CSS 4

Tailwind CSS 4 was a straightforward choice. We had used Tailwind 3 on previous projects and liked the utility-first model. Version 4 made it better in two ways that matter for a small team.

First, the configuration moved from JavaScript to CSS. There is no tailwind.config.js anymore. Your design tokens live in a @theme block inside your stylesheet:

@theme {
  --color-bg: #eae7e4;
  --color-text: #263238;
  --color-accent: #c0392b;
  --radius-md: 6px;
}

This is cleaner than maintaining a separate config file. Your colors, spacing, and typography are declared where they belong — in CSS. The values are real CSS custom properties, which means they work in browser DevTools, in arbitrary CSS, and in any component without importing a theme object.

Second, Tailwind 4 uses Lightning CSS under the hood. Builds are roughly five times faster than version 3. On a project this size the difference is milliseconds, but it compounds on larger codebases and CI pipelines.

The utility-first approach works well for a small team shipping fast. Every style is co-located with the markup it affects. There are no naming debates, no CSS file proliferation, and no specificity wars. When we need to change a card’s padding, we change p-6 in the template — not hunt through stylesheets for the right selector.

Content as Code

We write blog posts in MDX files stored directly in the repository. No CMS, no admin panel, no database. A new post is a pull request. Editing a typo is a commit. The entire content history lives in Git.

Astro’s content collections add type safety to this workflow. We define a schema with Zod, and every frontmatter field is validated at build time:

const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
  }),
});

If a post is missing a description or has a malformed date, the build fails with a clear error. No silent breakage, no runtime surprises. MDX also lets us embed components directly in prose — code blocks with syntax highlighting, callouts, or interactive demos if we ever need them.

A headless CMS would add value if we had non-technical editors or published daily. We do neither. For a small technical team writing occasional posts, MDX in the repo is the simplest solution with the fewest moving parts.

What We Skipped

Choosing a stack is as much about what you leave out as what you include.

GSAP and animation libraries. CSS animations and the View Transitions API cover everything we need — fade-ins, scroll-triggered reveals, page transitions. No JavaScript animation runtime needed. Our entire animation system is a few lines of CSS and an IntersectionObserver.

React as the primary framework. Astro renders to HTML. Adding React as the default rendering layer would mean shipping a runtime to every page for no benefit. If we ever need a complex interactive widget, we can add a single React island without changing the rest of the architecture.

Google Analytics. Privacy concerns for the EU market, cookie consent banners, and data we do not need. Plausible gives us page views and referrers in a 1KB script with no cookies. That is enough signal for a studio that grows through referrals.

A CMS. Contentful, Sanity, Strapi — all excellent tools, all unnecessary for our use case. We are two engineers who write Markdown. The overhead of a CMS (API keys, webhooks, content syncing, another service to maintain) does not justify the convenience when git push deploys our content.

The Boring Stack Wins

The best stack is the one that gets out of your way. Ours compiles to static HTML, styles with utility classes, and stores content in the repo. There are no microservices, no containers, no build pipelines that take minutes.

We can focus on what actually matters: writing useful content, designing clear interfaces, and shipping work that makes our clients look good. The technology disappears into the background, which is exactly where it should be.