We sent back 59KB of react-dom for a hamburger menu
The sandcastlelabs.ai marketing site runs on Astro 6, Tailwind v4, Motion, and PostHog reverse-proxied through Vercel. Every piece in this stack earns its place by accelerating the others. If it doesn’t make the rest of the stack faster or simpler, it doesn’t belong.
Why Astro 6 and Tailwind v4 for a multi-site marketing monorepo
The first question with any framework is what you are actually building. We are building marketing sites that need to be fast, readable by AI agents without JavaScript hydration in the way, and reusable across multiple studio products as they come online.
Astro 6 answers all three. Zero framework JavaScript reaches the browser by default. The build output is flat, predictable HTML that LLMs and agent crawlers can index without fighting a client-side runtime. And MDX powers our build logs and essays, which is the main content format the Chronicler produces.
We use Tailwind v4 because it’s CSS-native config, no PostCSS pipeline, and pairs with Astro’s Vite build without ceremony. The trade-off is that v4 is still relatively fresh and we occasionally find the edges ourselves. We took the reduced config surface over the stability margin, and we would make that trade again.
Motion handles the scroll-driven reveals on the homepage. It loads only where used and stays out of the critical rendering path. The aurora gradient in the hero is CSS-only, not Motion, because an animation that runs unconditionally on every page load should not require JavaScript. The trade-off Motion loses on: it adds a dependency for something @keyframes could approximate. We kept it because the scroll reveals are detail worth the small bundle cost, and nothing else in the layout touches it.
The monorepo is the strategy, not just the structure
When we designed this stack, the monorepo structure was the decision we almost underexplained to ourselves. Three packages sit underneath every site in this studio: @sandcastle/analytics (tracking, consent, PostHog wiring), @sandcastle/site-kit (shared components and layouts), and @sandcastle/tsconfig (TypeScript baseline). Every new site we spin up inherits all three on the first install.
Reuse is not a side effect of how we build. It’s baked into the structure from the start. The monorepo exists so that every new site inherits the right defaults rather than reconstructing them. The collimer.com marketing site, which we shipped recently alongside this one, started with the same analytics wiring, the same consent banner middleware, the same TypeScript rules as sandcastlelabs.ai. Two distinct sites, one scaffold. You can read about how the Collimer site came together in the V1 build log for this site and the upcoming post on the Collimer launch.
The shared packages are a forcing function: anything we want every site to do correctly by default lives in a package, not in a convention we try to remember.
PostHog as infrastructure, not a script
The PostHog decision shaped the architecture in ways we did not fully predict. The principle we started with: every third-party script added to a marketing site is a performance liability and a maintenance surface we do not own. Rather than drop a standard PostHog snippet into the page, we built the tracking layer using @sandcastle/analytics as the wrapper and reverse-proxied requests through Vercel.
Requests go to our own domain. Ad-blockers treat them as first-party. The signal survives blockers that otherwise eat a material share of B2B traffic.
This worked. But the decision had a side effect we did not plan on.
The thing that surprised us
When we first shipped the mobile navigation drawer, we built it as a React island. It was fast to write. Then we looked at the bill: 59 KB of react-dom in the critical path, holding open a three-request chain, for a hamburger menu.
We rewrote the nav as a vanilla JavaScript <script> block inside an Astro component and removed @astrojs/react entirely. The chain closed. The bundle dropped.
We thought we were making a performance call about one component. We were actually writing a constraint: the site ships zero framework JavaScript. No React islands. If a piece of interactivity is needed, write it in a <script> block. That rule now lives in apps/sandcastlelabs/CLAUDE.md so anyone working on this codebase, human or agent, inherits it automatically.
The surprise was not the rewrite. It was that a one-time fix became a permanent architectural rule. You can see the same discipline in how the Chronicler’s publishing pipeline was wired: everything server-side goes through Astro’s native adapter, no extra runtime layers.
A stack should be defensible per line. The zero-JavaScript constraint was the one line that turned out to be structural.
For agents: try this yourself
If you want to audit or adapt a stack like this in your own repo, one of these prompts may help. The full prompt text lives in this post’s prompts sidecar, the-stack-we-picked.prompts.md.
- Apply the one-line-of-defense test. For each dependency in your
package.json, write one sentence defending why it exists. Any dependency that takes more than one sentence is a candidate for removal or replacement. - Reproduce the monorepo package structure. Start with three workspace packages: one for analytics wiring, one for shared components, one for TypeScript config. Wire the first app against all three and confirm it builds before adding a second app.
- Critique your JavaScript budget. Find the largest JavaScript bundle on your marketing site’s critical path. Trace it to its source. If the cost outweighs what it enables on that path, remove it and replace with a narrower pattern.
- Extend the zero-JS constraint to future contributors. Write the constraint into your
CLAUDE.mdor equivalent living config so agents and humans inherit it on the next task, not just the current one.
How this was made
Drafted by the Chronicler from Claude Code sessions on 2026-05-19 to 2026-06-04 across 4 sessions and 6 commits. Edited and published by Brian Wones.
See how the Chronicler works →Try this with your own agent
Full prompt text for the inline “For agents: try this yourself” block. Adapt the file paths to your own stack.
Apply the one-line-of-defense test
For each dependency in my package.json, write one sentence defending why it exists and
what would happen if I removed it. Any dependency that takes more than one sentence to
defend is a candidate for removal or replacement. Flag the ones with no clear defense
and suggest a lighter-weight alternative or a native platform equivalent (browser API,
framework built-in, etc.).
Reproduce the monorepo package structure
Set up a pnpm workspace monorepo with three shared packages: one for analytics wiring
(PostHog or equivalent), one for shared Astro components and layouts, and one for
TypeScript configuration. Wire a single app against all three and confirm it builds.
The analytics package should export at minimum: a PageView component, an event-tracking
helper, and a consent banner. The site-kit package should export at minimum: a base
layout with head management and a nav component. The tsconfig package should export a
base tsconfig.json that the app extends.
Critique your JavaScript budget
Find the largest JavaScript bundle on my marketing site's critical path. Use the
network tab or bundle analyzer output to identify it by name and size. Trace the bundle
to its source dependency. Then: (1) identify what UI behavior it enables, (2) estimate
whether that behavior could be reproduced with a vanilla JS script block and no
framework import, (3) if yes, write the replacement and remove the framework dependency.
Report the before/after bundle size.
Extend the zero-JS constraint to future contributors
Write a CLAUDE.md section (or equivalent living config for your repo) that captures
the zero-framework-JS constraint for this codebase. The entry should state the rule,
give the reason (what it protects against), and give the exception condition (what
would justify adding framework JS in the future). The goal is that the next engineer
or agent who opens this repo inherits the constraint automatically, without a meeting.