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.