Skip to content
← All posts
Blog Meta · Architecture

How this site is built

Stack, architectural decisions and trade-offs of the site itself. Astro 6, content collections with Zod, reciprocal relations with an automated validator, zero JS where it adds nothing.

architecture astro cloudflare meta

This site was rebuilt from scratch between May and June 2026, on a stack that already began diverging from its own spec. What follows describes how it’s built now: which decisions were made, what got discarded, and what isn’t there yet. The post exists because one of the site’s own principles is that technical decisions get written in public, not assumed.

The stack

The site engine is Astro 6 with Svelte 5 for interactive islands. Tailwind v4 handles CSS via the Vite plugin, with design tokens declared directly in an @theme block instead of in a tailwind.config.js. Deployment to Cloudflare Workers with Static Assets: the same runtime serves the static HTML/CSS/fonts and the dynamic endpoints. MDX via @astrojs/mdx.

The reason for Astro as the engine — and not Next.js, not standalone SvelteKit — is the “zero JS by default” property. Astro renders static HTML at build time; any interactivity requires explicitly hydrating a component as an island, declaring it with the client:visible directive (or another similar one). This means most of the site ships ~0 bytes of JavaScript to the client: just HTML, CSS, and fonts. The few pieces that need live state (booking widget, contact form) are Svelte islands hydrated selectively.

The choice of Svelte 5 over React has three reasons: smaller bundle, less verbose syntax for interactive state, and the $state / $derived runes pattern in v5 is more predictable than useState + useEffect for the punctual cases we have. Svelte here isn’t ideology; it’s the right tool for the amount of interactivity we need (little).

Tailwind v4 with tokens in tokens.css means the site’s complete design system lives in a single CSS variables file — colors, typographic scales, spacing, rhythm. Changing the palette doesn’t require touching JavaScript or rebuilding a build pipeline; it’s a PR editing CSS variables that validates automatically because the classes consuming them are typed arbitrary values.

Cloudflare Workers with Static Assets covers three requirements simultaneously: global CDN for the assets, the same runtime executing dynamic endpoints (contact form writing to D1, Calendly webhook), and ~$0–5/month cost until there’s real traffic. Migrating from the previous infra was one of the triggers for the full rewrite.

Content as code

The site has five editorial content collections: products, services, playbooks, MCPs, verticals. All live as .mdx files grouped by collection under src/content/, one entry per file, with frontmatter validated by Zod schemas in src/content.config.ts.

// src/content.config.ts — fragment of the products collection
const productos = defineCollection({
  loader: glob({
    pattern: "**/*.{md,mdx}",
    base: "./src/content/productos",
    generateId: ({ data }) => `${data["slug"]}-${data["language"]}`,
  }),
  schema: z.object({
    slug: z.string(),
    name: z.string(),
    language: languageSchema,
    state: maturitySchema,
    repo: z.url().optional(),
    relatedVerticals: z.array(verticalSchema).default([]),
    relatedProducts: z.array(z.string()).default([]),
    relatedPlaybooks: z.array(z.string()).default([]),
    seo: seoSchema,
    publishedAt: z.coerce.date(),
    updatedAt: z.coerce.date().optional(),
  }),
});

Each product has one MDX per locale (atenea-es-CL.mdx, atenea-en-US.mdx), with identity composed of slug + language. Astro generates TypeScript types automatically from the Zod schema, so the rest of the code (the [slug].astro pages, the sidebar, the validators) consumes typed content without hand-writing interfaces.

The decision to keep content in the repo instead of an external CMS comes from wanting technical decisions — changing a product’s maturity state, adding a playbook, adjusting a service’s description — to be PRs with git history, not edits in a closed UI living in another database. Human review happens in the diff, not in a dashboard.

Reciprocal relations

The most interesting decision of the last cycle is documented in ADR-0017. Each collection can declare relations to other collections — a product related to a playbook, a vertical related to several products — but relations are declared at both ends. If Product A points to Playbook P, Playbook P must point back to Product A.

The motivation is editorial. When someone reads a playbook’s page, they should be able to discover the products where that playbook applies, without the product’s author being the only one who maintains the relation’s information. The graph becomes auditable and the cross-linking network closes in both directions.

The cost is double writing. A relation A → B requires editing two files (one per end), and since each one has an MDX per locale, that’s four edits per graph edge. The first time the pattern was introduced, human review let two reciprocity omissions slip through. The following phase inherited them and added a third one with a slug drift between locales.

To prevent this from happening again, the repo now has an automated validator:

// scripts/validate-relations.mjs — extract from the spec catalog
const RELATION_SPECS = [
  {
    from: "productos",
    field: "relatedVerticals",
    to: "verticales",
    reciprocalField: "relatedProducts",
  },
  {
    from: "productos",
    field: "relatedProducts",
    to: "productos",
    reciprocalField: "relatedProducts",
  },
  {
    from: "productos",
    field: "relatedPlaybooks",
    to: "playbooks",
    reciprocalField: "relatedProducts",
  },
  // ... 13 specs in total covering the 5 collections

  // Intentional asymmetries from ADR-0017 — products has no
  // relatedMcp nor relatedServicios by design:
  { from: "servicios", field: "relatedProducts", to: "productos", reciprocalField: null },
  { from: "mcp", field: "relatedProducts", to: "productos", reciprocalField: null },
];

for (const lang of ["es-CL", "en-US"]) {
  for (const spec of RELATION_SPECS) {
    allViolations.push(...validateRelationSpec(spec, collections, lang));
  }
  allViolations.push(...validateMcpVerticals(collections, lang));
}

The script runs as part of pnpm check (after astro check). If a relation A → B has no reciprocal, the build fails locally and in CI with a report grouped by source file. Intentional asymmetries are declared explicitly with reciprocalField: null, so they don’t get reported as violations.

The first run of the validator found three real pre-existing omissions that human review had let slip. All three were fixed in the same PR that introduced the validator. One of them was a slug drift between locales (productizado in es-CL but productized in en-US for the same service) that had been living in main for weeks.

Zero JS where it adds nothing

An explicit site constraint — declared in CLAUDE.md, in the ADRs, and enforced in review — is that any new component must justify why it needs hydration. It’s an operational rule, not an aspirational one.

The detail pages’ sidebar is the standard’s example: sticky with pure CSS, server-side rendered, no JavaScript except ~30 lines of IntersectionObserver to mark the active TOC item as the reader scrolls. No TOC framework, no “smooth scroll” library. The behavior that needs JS is isolated in a small block that lives next to the component using it:

// src/components/ui/DetailSidebar.astro — active TOC script
const observer = new IntersectionObserver(
  (entries) => {
    const visible = entries
      .filter((e) => e.isIntersecting)
      .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
    if (visible.length > 0) {
      setActive(visible[0]?.target.id ?? null);
    }
  },
  {
    rootMargin: "-80px 0px -70% 0px",
    threshold: 0,
  },
);
headings.forEach((h) => observer.observe(h));

The metric being measured is Lighthouse ≥ 95 on every page. Each change that reduces this gets rejected in review. The baseline is versioned in docs/lighthouse-baseline.md and wired to pr-checks as advisory — not as a gate yet. The plan is to promote it to a hard gate when it’s stable for two consecutive sprints.

Typography with personality

ADR-0014 replaced the original type stack (Fraunces + Geist Sans + JetBrains Mono) with Bricolage Grotesque as the single family for display and body, plus JetBrains Mono for code. The main reason: having one family for display + body reduces the reader’s cognitive contract and the fonts payload weight. Bricolage has typographic rhythm and a letter drawing that works in hero (28–72px) and in paragraph (15–17px) without feeling like they’re different fonts.

The subset is aggressive. Bricolage’s original TTF weighs ~280KB; after subsetting to extended Latin and dropping the opsz axis (which we weren’t using), the served WOFF2 weighs 77KB. Same for JetBrains Mono with only Latin glyphs and two weights. Total site fonts: under 120KB.

Explicitly forbidden fonts: Inter, Roboto, Arial, Space Grotesk, IBM Plex, Fraunces (the previous one), Geist, Mona Sans, Plus Jakarta, Recoleta, Instrument Sans. Not out of whim — because each one represents a recognizable, predictable B2B SaaS 2023 aesthetic, and the site is trying to signal something else.

Pre-rendered OG images

ADR-0016: each portfolio item (product, service, playbook, vertical, MCP) has its own OG PNG generated at build time via Satori + resvg. The preview on Twitter, LinkedIn, Slack, WhatsApp is specific to the shared URL. It’s not a single /og-default.png for the whole site.

Rendering happens in pnpm build, not at runtime, for two reasons: Satori + resvg running on Cloudflare Workers conflicts with the runtime’s WASM sandbox, and the site’s content is practically static — adding a product already implies a re-deploy, so pre-rendering at build adds no friction. The build script has cache via the frontmatter’s hash; if the content didn’t change, the PNG doesn’t regenerate.

Positioning honesty

ADR-0013 establishes a rule with teeth. The site shows no “trusted by” with invented logos, no fake counters like “3000 happy customers”, no testimonials without a real public case, and every portfolio product declares its real maturity in MDX frontmatter.

The maturities declared today are explicit:

  • Aether Telemetry is in public-beta
  • Thoth is in private-beta
  • Plutus, Daedalus, Themis and Atenea are in design

Detail pages show an explicit Note when a product is pre-GA: “X is in <maturity>. It’s not commercially available yet — the opening date isn’t confirmed.”

The pattern comes from Linear, early Cal.com, Vercel/Zeit, early Sentry — technical boutiques that grew by building in public instead of pretending to have traction they didn’t. For a boutique in the making, trying to fake scale attracts the wrong kind of clients.

What isn’t here

The site has no behavioral tracking beyond cookie-less Cloudflare Web Analytics (ADR-0010). No GA4, no Hotjar, no session replay. No cookie popups because we don’t use them. No newsletter — yet. There’s a contact endpoint, there’s Calendly booking, and that’s it.

The blog itself didn’t exist until today. The collection was declared in the schema but commented out as void blog to avoid glob-loader warnings. This post is the collection’s first item.

There’s a list of things we do want but got punted to later phases: validate that real Cloudflare Analytics telemetry is landing the Worker’s events, promote Lighthouse CI from advisory to a hard gate, write a shared helper to resolve relations from [slug].astro pages.

The site as artifact of the process

Building a portfolio site for a technical boutique in the making has a rare property: the site itself is a sample of the work. If the site is polished, the decisions are documented, the code is honest about where the real state is, then the reader gets a first signal — not of marketing, but of process — of how the boutique would work with a client.

The site rests on 17 ADRs, an active roadmap, and two validators wired into pnpm check (one for relation symmetry across content collections, another for JSX-trigger patterns in MDX). The decisions exist, are auditable, and shape what the reader sees here.

The next post will depend on what we find interesting to investigate next. In the meantime, if this resonates with something you’re building, let’s talk.

Did the post resonate?

If this resonates, there's more in the playbooks and the lab's products. Or book a conversation to discuss anything written here.