| name | astro |
| description | Astro framework code standards. Use when writing or reviewing Astro components (.astro), content collections, layouts, and pages. |
| user-invocable | false |
MANDATORY CO-SKILL: When this skill is active, ALWAYS also load the structure-astro skill. The structure-astro skill defines the canonical project structure for all Astro projects and is non-negotiable. If structure-astro is not already loaded, load it immediately before proceeding with any Astro work.
Astro Standards
Architecture
- Server-first by default — zero client JS unless explicitly needed
- Islands architecture: interactive components are opt-in exceptions, not the norm
- Static pages for content, server endpoints for dynamic data
- Prefer
.astro components over framework components when no interactivity needed
Client Directives (hydration)
client:load — interactive immediately on page load (modals, nav menus)
client:idle — interactive after page idle (below-fold widgets)
client:visible — interactive when scrolled into viewport (counters, carousels)
client:only="react" — client-render only, skip SSR (browser-only APIs)
- NEVER add
client:* to a component that doesn't need interactivity
- Prefer
client:visible or client:idle over client:load for performance
Content Collections
- Define schemas in
src/content.config.ts using Zod via astro/zod
- ALWAYS validate with
defineCollection + z.object({}) — NEVER untyped frontmatter
- Use
reference() for cross-collection relationships
- Loaders:
glob() for file-based, file() for single-file data sources
- Query with
getCollection() / getEntry() — NEVER raw file reads
import { defineCollection, reference } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
author: reference('authors'),
}),
});
Components
- Frontmatter (
---) for server-only logic: imports, data fetching, props
- Props typed via
Astro.props with explicit interface
- Slots for composition: named slots for complex layouts
- NEVER put side effects in frontmatter — it runs at build/request time
Pages & Routing
- File-based routing in
src/pages/
- Dynamic routes:
[slug].astro with getStaticPaths() for static
- API routes:
.ts files in src/pages/api/ returning Response objects
- Prefer static generation (
output: 'static') unless SSR is required
Environment Variables
How import.meta.env works under the hood
- Vite performs static string replacement at build time —
import.meta.env.X becomes the literal value in the output JS
PUBLIC_ vars are always inlined into both client and server bundles (by design)
- In current Astro (without
staticImportMetaEnv): non-public import.meta.env.SECRET is replaced with process.env.SECRET (runtime) — this is safe but will change
- With
experimental.staticImportMetaEnv (default in Astro 6.0): ALL import.meta.env values are inlined, including secrets — the literal secret value ends up in the server JS bundle
Security implications
- Inlined values are visible in Docker layers, build artifacts, source maps, and anywhere the bundle is stored
- Vite bug #17710: referencing a non-existent env var could dump the entire
import.meta.env object into the bundle (fixed, but illustrates the risk)
process.env.X is always a runtime lookup — the value never appears in any bundle
Rules for secrets
- ALWAYS use
astro:env/server with access: "secret" for sensitive values — this is the only future-proof pattern
- NEVER use
import.meta.env.SECRET_X for sensitive data — safe today, broken in Astro 6
- NEVER use raw
process.env in Astro — Vite replaces process.env.X with ({}).X in some contexts, and .env files are not auto-loaded into process.env
PUBLIC_ vars are fine via import.meta.env.PUBLIC_X — they are meant to be public
Canonical pattern for secrets
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
API_SECRET: envField.string({ context: "server", access: "secret" }),
PORT: envField.number({ context: "server", access: "public", default: 4321 }),
},
validateSecrets: true,
},
});
import { API_SECRET } from "astro:env/server";
Shared files (server + client): astro:env/server is FORBIDDEN
astro:env/server can ONLY be imported in server-only files (.astro frontmatter, middleware, API routes). If a .ts file is imported by BOTH server code AND client-side framework components (React, Svelte, etc.), importing astro:env/server will crash the browser with:
The "astro:env/server" module is only available server-side.
Fix: Use astro:env/client for vars needed in shared files. For server-only vars in a shared file, use import.meta.env.X (which Vite strips/replaces correctly per context).
import { SUPABASE_ANON_KEY } from "astro:env/client";
export function createServerSupabase(context) {
return createServerClient(import.meta.env.SUPABASE_URL, SUPABASE_ANON_KEY, { ... });
}
export function createBrowserSupabase() {
return createBrowserClient(window.location.origin, SUPABASE_ANON_KEY, { ... });
}
Eliminating PUBLIC_ prefix duplication with astro:env
import.meta.env.PUBLIC_X requires a PUBLIC_-prefixed env var to expose values to client JS. This forces duplication when infra generates X (not PUBLIC_X).
astro:env eliminates this. Define context: "client", access: "public" in the schema — Astro exposes the var to the browser using the ORIGINAL env var name. No PUBLIC_ prefix, no mapping, no duplication.
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
DB_URL: envField.string({ context: "server", access: "public" }),
ANON_KEY: envField.string({ context: "client", access: "public" }),
},
},
});
ANON_KEY is available client-side via import { ANON_KEY } from "astro:env/client" — no PUBLIC_ANON_KEY needed
- Infra/Docker passes
ANON_KEY as-is — one name everywhere
- For Docker builds: pass as build arg (
ARG ANON_KEY) so Astro bakes it into client JS
Quick reference
| Pattern | Safe for secrets? | Future-proof? |
|---|
import { X } from "astro:env/server" (access: "secret") | Yes | Yes |
import { X } from "astro:env/client" (access: "public") | Public only | Yes |
import.meta.env.SECRET (current Astro default) | Yes (today) | No (inlined in Astro 6) |
process.env.SECRET | Yes (runtime) | Fragile (Vite may rewrite) |
import.meta.env.PUBLIC_X | Public only | Legacy — prefer astro:env/client |
External Scripts & is:inline
In Astro, <script> tags are processed by Vite by default — they get bundled, hoisted, and treated as ES modules. This is NOT what you want for external CDN scripts.
The CORS problem
When Vite processes a <script src="https://cdn.example.com/lib.js"> tag:
- Vite's dev server sends a fetch with
Origin: http://localhost:4321
- Many CDNs (including
cdn.tailwindcss.com) return a 302 redirect (e.g., to a versioned URL)
- The redirect response lacks
Access-Control-Allow-Origin headers
- Browser blocks the script → CORS error, nothing works
[Error] Cross-origin redirection to https://cdn.tailwindcss.com/3.4.17
denied by Cross-Origin Resource Sharing policy:
Origin http://localhost:4321 is not allowed by Access-Control-Allow-Origin.
Status code: 302
Rules
- ANY external
<script src="https://..."> MUST have is:inline — this tells Astro to emit the tag verbatim, bypassing Vite entirely:
<!-- WRONG — Vite processes this, CORS breaks -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- CORRECT — emitted as-is, browser loads normally -->
<script is:inline src="https://cdn.tailwindcss.com"></script>
- Same applies to external
<link> stylesheets that Vite might try to process
is:inline scripts cannot use TypeScript, imports, or Vite features — they are raw browser scripts
Tailwind CSS setup
NEVER use the Tailwind Play CDN (cdn.tailwindcss.com) for production. It:
- Ships ~350 KB of runtime JS
- Generates styles client-side (no tree-shaking)
- Is explicitly marked "development only" by Tailwind docs
- Causes CORS errors in Astro dev without
is:inline
Proper setup:
pnpm astro add tailwind
This installs @astrojs/tailwind + tailwindcss, patches astro.config.mjs, and generates CSS at build time with full tree-shaking — zero runtime CDN dependency.
If CDN is required (e.g., quick prototype, spec explicitly says CDN):
<script is:inline src="https://cdn.tailwindcss.com"></script>
Review checklist (for code review / security review agents)
When reviewing Astro code, flag as critical: bug if:
- Any
<script src="https://..."> lacks is:inline — it WILL break in dev with CORS errors
- Tailwind Play CDN is used without
is:inline — page renders unstyled
Flag as important: security if:
- Tailwind Play CDN is used in production (no SRI, runtime JS, supply-chain risk)
- External CDN scripts lack
crossorigin="anonymous" (error reporting is opaque)
Styling
- Scoped
<style> blocks in .astro files by default
- Tailwind for utility classes when configured
is:global only when scoped styles genuinely can't work
- CSS custom properties for theming across components