Skip to content

Astro — Architecture and Scaffolding

FieldValue
TypeSkill Resource
Source~/.copilot/skills/frontend/references/astro.md
DescriptionNot 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/node and platform-locked adapters.

How it works

  1. Classify pages — marketing → output: 'static'; authed app shell → output: 'server'; mixed → output: 'hybrid'.
  2. Audit islands — list every interactive component; pick the least eager directive that still works (idle > visible > media > load; only only when SSR genuinely breaks).
  3. Content — Markdown/MDX in src/content/ with Zod schemas; type-safe access via getCollection.
  4. Backend — Astro endpoints (src/pages/api/*.ts) with Zod request/response; share schemas via packages/contracts.
  5. Adapter@astrojs/node standalone for Docker/k8s. Avoid platform-locked adapters unless the platform is the deploy target.
  6. Middlewaresrc/middleware.ts handles session/locale/headers; sets Astro.locals.user.
  7. CIastro check + astro build + Playwright smoke against the built bundle.

Examples

  • “Start a new Astro marketing site” → emit astro.config.mjs with output: 'static', content collections under src/content/, and zero client:* directives unless interactivity is named.
  • “Bundle size on our Astro dashboard ballooned” → audit every island, downgrade client:load to client:idle/visible where 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 check and 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 .npmrc is 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-task skill (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.

FileWhy it exists
.npmrcResolves @dmwd-io/* from GitHub Packages via ${NPM_TOKEN}; pins pnpm behavior
astro.config.mjsoutput: "server" + @astrojs/node standalone (the k8s-friendly adapter)
tsconfig.jsonExtends astro/tsconfigs/strictest; @/*src/* alias
biome.jsonOne binary for lint + format (replaces ESLint + Prettier)
src/content/config.tsZod-typed content collection stub (Astro 5 Content Layer glob loader)
Taskfile.ymlsetup/dev/check/lint/build/preview/ci — the standard task surface
package.jsonGenerated 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

  1. Confirm the target. Project directory and package name. If the dir exists with files, default to non-destructive (--force to overwrite).
  2. Check the registry token. @dmwd-io/design-system is private — it needs NPM_TOKEN (a GitHub PAT or CI GITHUB_TOKEN with read:packages). The script warns and skips install if it is unset, so the scaffold still completes.
  3. Run the scaffold. bash scripts/scaffold.sh <dir> [--name <pkg>] [--no-install] [--force] copies the template tree, writes package.json, and runs pnpm install.
  4. Verify it runs. task check (typecheck) then task dev.
  5. 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.
Terminal window
# the whole thing, from this skill's directory:
bash scripts/scaffold.sh ./my-app --name my-app
cd my-app && task dev

Self-rubric

  • One command did it. The engineer ran scaffold.sh once and got a runnable project.
  • Design-system resolves. .npmrc is in place and NPM_TOKEN handling is explicit (warned, not silently broken).
  • Stack matches ADR-027. Astro 5 SSR, @astrojs/node standalone, TS strict, Biome, pnpm, Zod content collections.
  • No secrets committed. NPM_TOKEN is an env var; .env/.env.local are gitignored.
  • Verified runnable. task check passes and task dev serves 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:

Terminal window
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:

TaskDoes
setuppnpm install — gated by an NPM_TOKEN precondition
devastro dev with HMR on HOST:PORT
checkastro check — typecheck .astro + .ts
lint / formatBiome lint / write-format
buildastro build — incremental via sources/generates
previewruns the built dist/server/entry.mjs
cichecklintbuild, the gate CI runs
resetdestructive 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 build
WORKDIR /app
COPY 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-lockfile
COPY . .
RUN pnpm astro build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV HOST=0.0.0.0 PORT=4321
EXPOSE 4321
CMD ["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:

Terminal window
cd <target>
task check # astro check — typecheck passes
task lint # biome — clean
task build # produces dist/server/entry.mjs
task dev # serves src/pages/index.astro

References