Astro — Architecture and Scaffolding
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/frontend/references/astro.md |
| Description | Not specified |
Source Content
Astro — Architecture and Scaffolding
Astro application architecture (output modes, hydration, content collections, adapters) plus the one-command project scaffolder. Absorbed from the former astro-architect and create-astro-project skills.
Part 1 — Architecture
Astro’s value is shipping near-zero JS by default; the moment everything becomes client:load it’s just a slower Next.js. Lock in the right output mode per page and the least eager directive per island.
Use for
- Greenfield Astro projects: pin defaults before features pile up.
- Migrating from Next.js / Gatsby / Hugo / Jekyll.
- Hydration audits when bundle size has crept past budget.
- Backend-on-Astro: routing API endpoints alongside pages.
- Choosing between
@astrojs/nodeand platform-locked adapters.
How it works
- Classify pages — marketing →
output: 'static'; authed app shell →output: 'server'; mixed →output: 'hybrid'. - Audit islands — list every interactive component; pick the least eager directive that still works (
idle>visible>media>load;onlyonly when SSR genuinely breaks). - Content — Markdown/MDX in
src/content/with Zod schemas; type-safe access viagetCollection. - Backend — Astro endpoints (
src/pages/api/*.ts) with Zod request/response; share schemas viapackages/contracts. - Adapter —
@astrojs/nodestandalone for Docker/k8s. Avoid platform-locked adapters unless the platform is the deploy target. - Middleware —
src/middleware.tshandles session/locale/headers; setsAstro.locals.user. - CI —
astro check+astro build+ Playwright smoke against the built bundle.
Examples
- “Start a new Astro marketing site” → emit
astro.config.mjswithoutput: 'static', content collections undersrc/content/, and zeroclient:*directives unless interactivity is named. - “Bundle size on our Astro dashboard ballooned” → audit every island, downgrade
client:loadtoclient:idle/visiblewhere it works, and report bytes saved per route. - “Migrate this Next.js app to Astro” → classify each route as static/server/hybrid, port middleware, and pick the Node adapter for k8s.
Self-rubric
- Output mode justified per page type, not picked uniformly.
- No blanket
client:load— every directive is the least eager that works. - Zod-validated content collections (
src/content/config.ts) — not raw frontmatter. - Adapter matches deploy target and isn’t platform-locked unnecessarily.
- Auth handled in middleware, not duplicated per page.
- Build verified with
astro checkand a Playwright smoke. - Validated:
scripts/check_astro.sh [TARGET_PROJECT_PATH]exits 0.
scripts/check_astro.sh [TARGET_PROJECT_PATH] runs astro check when the target is a real Astro project with the CLI reachable (hard-fails on compiler errors); otherwise degrades to grep-based heuristics (content-collection z.object( schemas, uniform client:load eagerness) which only warn, never crash.
Part 2 — Project scaffolding
Scaffold a new Astro 5 (SSR) project the dmwd-io way — one command lays down a TypeScript-strict, @dmwd-io/design-system-wired, pnpm + Biome + Zod-content-collections starter with a prebuilt private .npmrc, a @astrojs/node standalone adapter for k8s, and a Taskfile.
Pinned to: Astro 5.18.x · @astrojs/node 9.x · Biome 2.5.x · TypeScript 6.x
A junior engineer should get the standard Astro starter from one command — no reinvention, no guessing which adapter, no fighting the private registry. This scaffolds; it does not redesign the stack. The architecture decisions behind it are in Part 1 above and ADR-027.
Use for
- Starting a brand-new Astro 5 app that must match the dmwd-io stack.
- Bootstrapping a repo with the design-system already resolvable (the
.npmrcis prebuilt). - Standing up the standard config set (TypeScript strict, Biome, Zod content collections, Taskfile) in one shot.
- Showing a junior engineer the canonical “how do I start an Astro project here” path.
Don’t use for
- Authoring or extending the Taskfile vocabulary → the
go-taskskill (this ships a starter Taskfile; that skill owns the grammar). - Scaffolding a Go service or a React SPA — different front doors.
What it lays down
Each template is a real, reusable file under templates/astro-starter/ — copied verbatim, not regenerated.
| File | Why it exists |
|---|---|
.npmrc | Resolves @dmwd-io/* from GitHub Packages via ${NPM_TOKEN}; pins pnpm behavior |
astro.config.mjs | output: "server" + @astrojs/node standalone (the k8s-friendly adapter) |
tsconfig.json | Extends astro/tsconfigs/strictest; @/* → src/* alias |
biome.json | One binary for lint + format (replaces ESLint + Prettier) |
src/content/config.ts | Zod-typed content collection stub (Astro 5 Content Layer glob loader) |
Taskfile.yml | setup/dev/check/lint/build/preview/ci — the standard task surface |
package.json | Generated by the script; pins the stack versions in one place |
The script also writes .gitignore, .env.example (with the NPM_TOKEN slot), and a minimal src/pages/index.astro so task dev shows a page immediately.
How it works
- Confirm the target. Project directory and package name. If the dir exists with files, default to non-destructive (
--forceto overwrite). - Check the registry token.
@dmwd-io/design-systemis private — it needsNPM_TOKEN(a GitHub PAT or CIGITHUB_TOKENwithread:packages). The script warns and skips install if it is unset, so the scaffold still completes. - Run the scaffold.
bash scripts/scaffold.sh <dir> [--name <pkg>] [--no-install] [--force]copies the template tree, writespackage.json, and runspnpm install. - Verify it runs.
task check(typecheck) thentask dev. - Don’t redesign the stack here. Version bumps go in
scripts/scaffold.sh(one block of pinned versions) and in the templates — never improvised per project.
# the whole thing, from this skill's directory:bash scripts/scaffold.sh ./my-app --name my-appcd my-app && task devSelf-rubric
- One command did it. The engineer ran
scaffold.shonce and got a runnable project. - Design-system resolves.
.npmrcis in place andNPM_TOKENhandling is explicit (warned, not silently broken). - Stack matches ADR-027. Astro 5 SSR,
@astrojs/nodestandalone, TS strict, Biome, pnpm, Zod content collections. - No secrets committed.
NPM_TOKENis an env var;.env/.env.localare gitignored. - Verified runnable.
task checkpasses andtask devserves the index page. - Validated:
scripts/verify_scaffold.sh [PROJECT_DIR]exits 0.
scripts/verify_scaffold.sh [PROJECT_DIR] fails listing any missing required file (astro.config.mjs, tsconfig.json, biome.json, src/content/config.ts, Taskfile.yml, .npmrc); optionally runs pnpm install + astro check if those tools are on PATH (degrades to a warning, never a hard failure, when absent).
Part 3 — Scaffold internals
What each template decides, why, and how to change it. Read this only when bumping versions, debugging the private-registry install, or explaining a config choice.
Version pinning (single source of truth)
All pinned versions live in one block at the top of scripts/scaffold.sh:
ASTRO_VERSION="^5.18.2"NODE_ADAPTER_VERSION="^9.5.5"DESIGN_SYSTEM_VERSION="latest"BIOME_VERSION="^2.5.0"TYPESCRIPT_VERSION="^6.0.3"TS_PLUGIN_VERSION="^1.10.0"To bump the stack, edit those constants and the matching $schema URL in templates/astro-starter/biome.json. Do not edit a generated project’s package.json by hand and call it the new default — the template is the source of truth. @astrojs/node major versions track Astro majors: the 9.x adapter line pairs with Astro 5; do not mix a 10.x adapter (Astro 6) into a 5.x project.
The private registry (.npmrc + NPM_TOKEN)
@dmwd-io/design-system is published to GitHub Packages, not the public npm registry. templates/astro-starter/.npmrc does two things:
@dmwd-io:registry=https://npm.pkg.github.com//npm.pkg.github.com/:_authToken=${NPM_TOKEN}The token is read from the NPM_TOKEN environment variable and is never written to disk. Provision it as a GitHub Personal Access Token (classic or fine-grained) with the read:packages scope, or in CI use the job’s GITHUB_TOKEN (which has read:packages against the same org).
Failure mode: if NPM_TOKEN is unset, pnpm install returns 401 Unauthorized on the @dmwd-io scope only — every public dependency still resolves. The scaffold script detects an empty NPM_TOKEN, skips the install, and prints the export-then-reinstall instruction so the scaffold still completes cleanly.
Why output: server + the Node adapter
ADR-027 names Astro 5 SSR as the canonical TypeScript app runtime, and the deploy target is Kubernetes. Two choices follow:
output: "server" — Every route is server-rendered by default. A page that is genuinely static opts out with export const prerender = true; in its frontmatter — the explicit exception, never the silent default. This is the inverse of a marketing-only site (output: "static"); choose per project using Part 1 above.
adapter: node({ mode: "standalone" }) — The standalone Node adapter bundles a complete HTTP server at ./dist/server/entry.mjs that reads HOST/PORT from the environment. That is exactly what a Kubernetes Deployment + Service expects — no platform runtime, no serverless shim. Platform-locked adapters (@astrojs/vercel, @astrojs/netlify, @astrojs/cloudflare) are deliberately avoided unless that platform is the actual deploy target.
TypeScript: strictest + the @/ alias
tsconfig.json extends astro/tsconfigs/strictest, which turns on strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, and friends. The @/* → src/* path alias keeps imports stable as the tree moves. verbatimModuleSyntax is on so import type is enforced (Biome’s useImportType rule agrees). .astro files are typechecked by astro check, not tsc — wire task check into CI for that.
Biome scope (and why .astro is excluded)
Biome formats and lints TS/TSX/JS/JSON. It does not yet format .astro single-file components, so they are excluded in biome.json (!**/*.astro). Astro components are formatted by Astro’s own Prettier plugin (prettier-plugin-astro), which the editor extension runs. Keeping the two tools in separate lanes avoids a format war over the same file. The $schema URL is version-pinned to the Biome release in the dev dependency so editor autocomplete matches the installed binary.
Content collections on Astro 5 (Content Layer)
Astro 5 replaced the legacy type: "content" collections with the Content Layer API. The stub uses it:
import { defineCollection, z } from "astro:content";import { glob } from "astro/loaders";
const blog = defineCollection({ loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }), schema: z.object({ /* ... */ }),});Two rules: import z from astro:content (not the zod package directly — Astro’s re-export carries the image() helper and stays version-aligned), and validate every collection with a schema so getCollection("blog") is fully typed and a frontmatter typo fails astro check instead of shipping. The brief keeps the file at src/content/config.ts; Astro 5 also accepts src/content.config.ts — either resolves, the directory form is used here.
The Taskfile surface
The starter Taskfile.yml follows the go-task skill’s conventions (kebab-case names, UPPERCASE vars, a desc on every task). The standard surface:
| Task | Does |
|---|---|
setup | pnpm install — gated by an NPM_TOKEN precondition |
dev | astro dev with HMR on HOST:PORT |
check | astro check — typecheck .astro + .ts |
lint / format | Biome lint / write-format |
build | astro build — incremental via sources/generates |
preview | runs the built dist/server/entry.mjs |
ci | check → lint → build, the gate CI runs |
reset | destructive clean + reinstall, behind a prompt |
Extending this Taskfile is the go-task skill’s job, not this one’s.
Containerizing for k8s
The standalone build is container-ready. A minimal multi-stage Dockerfile (not shipped by the scaffold):
FROM node:20-slim AS buildWORKDIR /appCOPY package.json pnpm-lock.yaml .npmrc ./RUN --mount=type=secret,id=npm_token \ NPM_TOKEN="$(cat /run/secrets/npm_token)" corepack enable && pnpm install --frozen-lockfileCOPY . .RUN pnpm astro build
FROM node:20-slimWORKDIR /appCOPY --from=build /app/dist ./distCOPY --from=build /app/node_modules ./node_modulesENV HOST=0.0.0.0 PORT=4321EXPOSE 4321CMD ["node", "./dist/server/entry.mjs"]The NPM_TOKEN enters the build as a BuildKit secret so it never lands in an image layer.
Verifying the scaffold
After scaffold.sh, prove the project is sound:
cd <target>task check # astro check — typecheck passestask lint # biome — cleantask build # produces dist/server/entry.mjstask dev # serves src/pages/index.astroReferences
scripts/check_astro.sh—astro checkwrapper + hydration/content-schema heuristics.scripts/scaffold.sh— the one-command scaffolder (pinned versions live at the top).scripts/verify_scaffold.sh— required-file check + optionalpnpm install/astro checksmoke test.templates/astro-starter/— the verbatim files it lays down.- Astro docs · Content collections · View Transitions · Node adapter
- Jason Miller — Islands Architecture
- Biome · pnpm · GitHub Packages npm registry
- ADR-027 — Default Tech Stack — the binding stack this scaffold encodes. </content>