← All posts

Astro 6 + Specific.dev: the stack ano.chat ships on

7 min read #engineering #astro #specific #performance #marketing-site

A marketing site is a stack of pages a human reads once. It is not a React app. It has no session. It has no state machine. Nothing on it is realtime.

Every marketing site you have visited in the last five years was built like one anyway. A React tree, a hydration pass, half a megabyte of JavaScript to render text that was already on the wire, deployed through a build farm an ocean away from the person reading the page. They all feel the same: a flash of nothing, a flash of text, the page settles.

We were not going to ship another one.

The site you are reading is three decisions. Specific.dev is where it runs. Astro 6 is what builds the pages. Content collections are how the words become pages. Together they are why this page comes up as fast as it does.

Specific.dev: the deploy is the merge

The thing we use the most on Specific.dev is the thing they do not put on a feature page: the GitHub integration.

git push origin main

That is the deploy. Specific.dev watches the branch, builds the Astro app, runs migrations against the env’s Postgres, and flips traffic at the edge when the new version is healthy. No specific deploy, no CI step we own, no waiting in front of a yellow check mark. The merge is the deploy.

Two things fall out of that:

  1. Preview environments are free. Open a branch, push, get a URL with real data. Land it, the preview goes away.
  2. Rollbacks are trivial. A bad commit gets a revert, which is another push, which is another deploy. The same loop.

Specific.dev itself is a cloud platform built for coding agents: one CLI, one config file, every primitive a small product needs (managed Postgres with read replicas, S3-compatible storage, a Redis-compatible cache, secrets, a global CDN with auto-generated TLS) on the same control plane. The marketing site uses a thin slice: frontend hosting, Postgres for signups and admin state, a couple of secrets. The chat product uses more.

Where it bites

The merge-is-the-deploy story is true for the Astro origin and not true for the Cloudflare Workers in front of it. The workers (an edge HTML cache, a dev-domain redirect) live in the same repo but deploy on a separate track: a wrangler deploy from each worker directory, run by a human. The day someone pushes a worker change to main and forgets the wrangler deploy, git and the edge drift and the bug looks like the worker code in the repo behaving differently than the worker code in production. We have done this. It is documented in the repo and we still occasionally redo it.

That is the kind of seam a newer platform has and an older one would have smoothed over. Specific.dev does not own the edge in front of itself the way Cloudflare does. We chose to keep both, and pay the seam.

What we ruled out

  • Vercel. Excellent frontend platform. Bring your own database. We did not want a second control plane for the Postgres the admin tools need.
  • Netlify. Same shape, same answer.
  • Fly / Render / Railway. Good runtimes. Each ships a slice of what Specific.dev gives in one box. Composing them is up to you.
  • Cloudflare Pages plus D1. Tempting. Postgres is what our team writes against. D1 is not.

Specific.dev gave us Postgres, CDN, secrets, preview envs, and the GitHub auto-deploy in one place. That is the bar.

Astro 6: HTML first, JavaScript on request

The site has a performance budget written into the repo. Mobile Lighthouse at or above 90. LCP under 2.5 seconds. FCP under 1.8 seconds. CLS under 0.05. Total payload under 1.5MB on the home page and 1MB elsewhere. Anything that drops the score by more than five points does not merge.

The homepage today is 37KB of gzipped HTML on the wire, 152ms to first byte from the EU edge. The rest of the site sits under that. Holding that line against an SPA framework is a job. Holding it against Astro 6 is the default.

Astro is a content-first web framework. A page is an .astro file: HTML with a TypeScript frontmatter block on top. The build outputs HTML. JavaScript ships on the components you mark with a client:* directive, and only those. The default is zero JS. The opt-in is a single attribute.

---
import Counter from "../components/Counter.tsx";
---

<h1>Team chat with Claude Code built in.</h1>

<Counter client:visible />

The hero on this page is HTML. A client:visible island hydrates when it scrolls into view, and only then. Nothing else on the page costs the reader a kilobyte of JavaScript.

That is the user-experience argument in four bullets:

  • First paint feels like final paint. The browser receives HTML that already knows what it is rendering. No hydration flash, no skeleton, no second pass of layout.
  • Less JavaScript means less time to interactive. On a mid-range phone on 4G the gap between “page visible” and “page responsive” is the JS bundle. Astro keeps it tiny because most pages have nothing to hydrate.
  • Navigations feel like an SPA without shipping one. One <ClientRouter /> in the layout, prefetchAll in the config, and every link in the viewport is fetched ahead of the click. The transition itself uses the CSS View Transitions API.
  • Real server routes when they earn their keep. Astro 6 ships a Node adapter and stable type-safe Actions. The three forms on the site (signup, contact, apply) are defineAction() handlers. No REST endpoint, no client SDK, no shared type package, no drift.

Astro 6 also graduated a clutch of features that used to be experimental: a built-in Fonts API that handles subsetting, preloading and @font-face from one config block; Live Content Collections for the collections that need a network read per request; and CSP through security.csp instead of hand-rolled middleware. The dev server runs on the Vite 7 Environment API, so booting in wrangler dev parity is one flag away.

The alternatives:

  • Next.js. Built for shipping React apps from the server. A marketing site is not a React app. We did not want to fight a framework for every kilobyte.
  • Remix / React Router 7. Same shape, same problem. Beautiful for an app, overhead for a page.
  • Nuxt / SvelteKit. Fine frameworks. Wrong runtime for our team.
  • Hugo, Jekyll, Eleventy. Fast and small and we love them on principle. They die the moment a page needs a server route, and admin OAuth needs one.

Content collections: typed markdown, zero CMS

The site is full of writing. Blog posts. Guides. Job listings. The detail page you are reading right now is a markdown file on disk.

Astro’s Content Layer binds a folder of files to a Zod schema. Frontmatter that drifts off schema fails the build instead of showing up broken in production.

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const blog = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
  schema: z.object({
    title: z.string(),
    summary: z.string().max(220),
    author: z.object({
      name: z.string(),
      role: z.string().optional(),
    }),
    posted_at: z.date(),
    status: z.enum(["draft", "published", "retired"]).default("published"),
    tags: z.array(z.string()).default([]),
    hero_image: z.string().optional(),
  }),
});

const guides = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/guides" }),
  schema: /* ... */,
});

export const collections = { blog, guides };

The status field is the whole publishing workflow. draft hides the entry and 404s its detail page. published shows it. retired keeps the URL alive with a tombstone so old links do not break. The jobs collection uses the same shape for closed roles. One idea, three surfaces.

Two collections cover almost the whole site. Blog and guides share the listing template, the detail template, the SEO helpers, the OG image generator. A new post is a markdown file in src/content/blog. A new guide is a markdown file in src/content/guides. There is nothing else to wire up.

A headless CMS would have given us a UI to draft in. It would also have given us a network read on every build, a third party outage in our deploy path, and a content model that lives somewhere other than the repo. Markdown plus Zod lives exactly where the code does, ships with the same commits, versions with the same git, and validates at the same time the rest of the build does.

What it adds up to

Three decisions, one outcome. The site loads fast because Astro ships HTML. It deploys fast because Specific.dev fires on merge. It grows fast because adding a page is editing a markdown file the build validates.

Then the smaller wins. RSS is a thirty-line route. The sitemap is an integration we configured once. The OG image endpoint is a single .ts file. View transitions are three lines of config. Postgres lives on the same control plane as the frontend. Each of those used to be a side project. Now each is part of the stack.

The point

We did not pick this stack because it was fashionable. We picked it because the experience we wanted on ano.chat could not be built on a server-first SPA hosted on a build platform that ignores the database. Astro starts where SPA frameworks have to claw their way back to. Specific.dev runs the database and the frontend on the same control plane. Content collections keep the content in the same git history as the code.

The chat app is React and Zero, because the chat app is alive. The marketing site is Astro on Specific.dev, because the marketing site is read, and the marketing site ships.

Different tools, different jobs.

If you want to use the thing we built, get on the beta. If you want to build the thing we built, we are hiring.