BUILD LOG // 01

How we shipped aiso-hub on Astro, Sanity, Cloudflare

The stack, the timeline, the trade-offs, and the one decision we'd make differently.

2026-04-17 ·9 min read ·AISO-DEV build-logastrosanitycloudflare

TL;DR

  • Stack: Astro 5, Tailwind 4, @sanity/client, Cloudflare Pages, Resend, PostHog, Pagefind, self-hosted Inter Variable.
  • Scope: 30+ pages, full i18n (en / pt-pt / fr), Sanity-CMS editorial blog, lead form, newsletter, exit-intent, citation-schema JSON-LD.
  • Timeline: skeleton to production in ~6 weeks with iterative content load.
  • Lighthouse: 95+ performance / 100 SEO / 95+ accessibility on the core templates.
  • One reversal: we’d self-host the fonts on day one, not week three.

What we were solving

aiso-hub.com is AISO Group’s AI search optimization practice. The site needs to do three things well:

  1. Rank and be citable. It sells against Google AI Overviews, Perplexity, ChatGPT web search, and an emerging AISO category. It has to walk the walk.
  2. Host long-form editorial. Hub’s content pillar is long, evergreen authority content.
  3. Ship multilingual (en / pt-pt / fr) without a CMS that turns editorial into a chore.

The old site was a single-page Next.js hero. Fast to ship, impossible to grow. The brief was a textbook custom AI web build: static performance budget, editorial CMS, multilingual routing, and AI-citation hygiene baked in.

The stack choices

Framework: Astro 5. Static by default, component islands where we need interactivity. No hydration penalty on article pages. Astro’s content model (src/content/ + Sanity) let us co-locate marketing pages and CMS-backed editorial without a second app. It’s also trivially crawlable - important when you’re optimizing for both Google and AI citation.

Styling: Tailwind 4 + a small utility layer. We promoted a handful of patterns into global.css - .section, .container, .kicker, .btn-primary, .lead, .h3, .insight-card__*, .article-body. The rest is Tailwind inline. This keeps the design system small enough to read in one sitting and big enough that new pages don’t drift.

CMS: Sanity. Editorial content on Sanity, marketing pages on Astro. The split works because editors live in Sanity Studio (fast, structured, good image handling) and the marketing team commits marketing-copy changes via PR (good for versioning and review). Sanity’s GROQ + @sanity/client integrate into Astro’s static build cleanly.

Schemas we settled on: insightArticle, author, faq, contentPiece, plus localeString, localeText, localeMarkdown for i18n. One dataset per environment (production + preview), one project.

Infra: Cloudflare Pages + Workers. Pages for the static build. Workers for the lead form endpoint (Resend-backed) and the newsletter signup. Global CDN is free and the preview-URL-per-PR flow changed how we review content.

Analytics: PostHog. Self-hosted on the EU region, no third-party tracking scripts on the page.

Search: Pagefind. Client-side search across the whole site, zero infra. Prebuilt at build time. Blows away any in-page search we’ve used before.

Architecture at a glance

                ┌─────────────────┐
                │  Sanity Studio  │  (editors)
                └────────┬────────┘
                         │ GROQ

 ┌────────────────┐   Astro build   ┌──────────────────┐
 │  Content/ MDX  │────────────────▶│  Static HTML +   │
 │  (marketing)   │                 │  per-locale dirs │
 └────────────────┘                 └─────────┬────────┘
                                              │ deploy

                                    ┌──────────────────┐
                                    │  Cloudflare Pages│
                                    └──────────────────┘

                   ┌──────────────────────────┼──────────────────┐
                   ▼                          ▼                  ▼
              ┌──────────┐             ┌────────────┐      ┌──────────┐
              │ Pagefind │             │ CF Workers │      │ PostHog  │
              │ (static) │             │ (forms)    │      │ (EU)     │
              └──────────┘             └────────────┘      └──────────┘

The timeline

  • Week 1 - Design + IA. Wireframes, page inventory, content model, component spec. Locked on the .section / .container primitive before a single component was written.
  • Weeks 2-3 - Skeleton. BaseLayout, Nav, Footer, hero, solution cards, article template, insight card grid, Sanity schemas. First Cloudflare preview URL live on day 10.
  • Weeks 4-5 - Content + features. Lead form, newsletter signup, exit-intent, breadcrumb, TOC auto-builder on article pages, author panel, related articles, citation schema, multilingual routing.
  • Week 6 - Perf, a11y, polish. Self-host Inter Variable (we’d started on Google Fonts - mistake, see below), axe-core a11y sweep, image optimization, Lighthouse tuning.
  • Ongoing - content load. Editorial team ships 1-2 long-form posts per month. Marketing copy iterates via PR.

Lighthouse numbers

On the home, solutions, and article templates:

  • Performance: 95-99
  • Accessibility: 95-98
  • Best Practices: 100
  • SEO: 100

LCP sits under 1.2s on a fast connection; CLS is near zero (self-host the fonts, ship width/height on images); TBT stays negligible because Astro doesn’t hydrate content pages.

The same shape ships well across our AI implementation services - the build below is a working reference, not a one-off.

What broke (or almost did)

  • Google Fonts as a <link>. First week’s build loaded four Inter weights via Google’s CDN. CLS was terrible on slow 3G and we were leaking referrer to Google. Fix: @fontsource-variable/inter, one file, self-hosted, font-display: swap. Lesson: self-host from day one.
  • Sanity datasets. We started with a single dataset and paid for the production accidentally-edited-by-preview pain. Moved to production + preview datasets on day 14. No regrets.
  • i18n routing. Astro’s i18n story is young. We ended up with explicit per-locale directories (/en/, /pt-pt/, /fr/) and a centralized locale strings table, rather than middleware-driven. Simpler to read, trivially static.
  • Mobile menu a11y. We shipped v0 without aria-expanded / aria-controls on the toggle. A11y sweep caught it week 6. Lesson: add the attributes when the toggle is first written, not retroactively.

One decision we’d reverse

Starting on Google Fonts. It cost a week of chasing CLS and LCP issues and gave us nothing. Self-host from day one - the @fontsource-variable/inter package is three lines of setup.

What the stack doesn’t do

This is the set that fits marketing + editorial. We wouldn’t ship a high-interaction SaaS dashboard on Astro alone - we’d pair it with a Next.js app for the authenticated surface and use Astro for the marketing frontend. Use the right tool for the surface.

The files that did the heavy lifting

  • src/styles/global.css - the utility layer.
  • src/layouts/BaseLayout.astro - canonical URL, OG/Twitter/Citation meta, JSON-LD, PostHog, Resend wiring.
  • src/components/InsightCard.astro + src/pages/insights/[slug].astro - the editorial template.
  • src/components/LeadForm.astro - Cloudflare Worker-backed form with Resend.
  • scripts/build-pagefind.sh - postbuild search index.

Reusing this

We ship the same stack for external clients - if you’d rather hire AI developers than spin up an in-house team for one project, this is the shape of the engagement. Prices are on the pricing page. The full build log for the multi-agent orchestrator is next up.


Want the same shape? Scope a custom web build →

Prefer a scoping call first? Free AI Readiness Audit →

BUILD // NEXT

Got a project that should be a build log?

Scope a project. We'll ship it. You get a system; we may write about it (with permission).