Project & .astro Files
The project layout
Section titled “The project layout”A typical Astro project has a small, conventional structure. Here it is proven, not paraphrased — the real scaffold from npm create astro@latest -- --template minimal on Astro 7.3.5:
$ npm create astro@latest -- --template minimal --no-install --no-git --yes ./mysite$ find mysite -not -path '*/node_modules*' -not -path '*/.git*' | sortmysitemysite/.vscodemysite/.vscode/extensions.jsonmysite/.vscode/launch.jsonmysite/AGENTS.mdmysite/CLAUDE.mdmysite/README.mdmysite/astro.config.mjsmysite/package.jsonmysite/publicmysite/public/favicon.icomysite/public/favicon.svgmysite/srcmysite/src/pagesmysite/src/pages/index.astromysite/tsconfig.json$ cat mysite/package.json{ "name": "mysite", "type": "module", "version": "0.0.1", "engines": { "node": ">=22.12.0" }, "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "astro": "^7.3.5" }, "allowScripts": { "esbuild": true }}engines.node is >=22.12.0 — verified straight from the published package (npm view astro engines → { node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0' }); Astro won’t run on an older Node. Two things worth noticing in a minimal scaffold: there’s no src/content.config.ts, src/layouts/, or src/components/ yet — those appear as you add content collections and components, they’re not required to exist — and the template now ships AGENTS.md/CLAUDE.md with a coding-agent-oriented quick reference (background dev server, doc links), not just human-facing docs.
A larger, growing project fills in the rest of the reserved layout:
my-site/├── astro.config.mjs # config: integrations, adapter, output mode├── src/│ ├── pages/ # file-based routing — each file is a route│ ├── components/ # reusable .astro (and framework) components│ ├── layouts/ # shared page shells│ ├── content.config.ts # content collections config (build-time)│ ├── live.config.ts # live collections config (request-time data)│ ├── middleware.ts # runs before every request│ ├── actions/index.ts # server Actions (defineAction)│ ├── fetch.ts # Astro 7+: advanced routing entrypoint (reserved)│ ├── env.d.ts # ambient types: App.Locals, ImportMetaEnv│ └── styles/└── public/ # static assets served as-is (favicon, robots.txt)| Path | Role | Reserved in Astro 7? |
|---|---|---|
src/pages/ |
File-based routing — each file becomes a route | Yes — the folder itself is load-bearing |
src/content.config.ts |
Declares build-time content collections and their Zod schemas | Yes — special filename, auto-imported |
src/live.config.ts |
Declares live collections (request-time data, needs an adapter) | Yes — new in 7.0, special filename |
src/middleware.ts |
onRequest — runs before every request |
Yes — special filename |
src/actions/index.ts |
Exports the server object of Actions |
Yes — special path |
src/fetch.ts |
Advanced-routing entrypoint, auto-imported if present | Yes — new reserved name in Astro 7.0 (see below) |
public/ |
Copied to the output verbatim, served at the root, unprocessed | Yes — the folder itself |
.astro/ |
Generated types (astro sync) — .astro/types.d.ts, .astro/content.d.ts |
Not user-owned; never hand-edit |
Two folders are load-bearing:
src/pages/is file-based routing:src/pages/index.astro→/,src/pages/about.astro→/about,src/pages/blog/[slug].astro→/blog/:slug. The file’s location is the route.public/is copied to the output verbatim — files here are served at the root, unprocessed.
src/fetch.ts — the new reserved name in Astro 7
Section titled “src/fetch.ts — the new reserved name in Astro 7”Astro 7.0 introduced advanced routing, and src/fetch.ts is its special filename — Astro imports it automatically to configure routing behavior, the same way it already auto-imports src/middleware.ts. Straight from the official v7 upgrade guide:
Anatomy of a .astro file
Section titled “Anatomy of a .astro file”The .astro file is Astro’s own component format. It has two parts separated by a code fence (---):
---// 1. The component script (frontmatter). Runs on the SERVER only.// JavaScript/TypeScript here: imports, data fetching, props, logic.import Card from '../components/Card.astro';
const title = "Latest posts";const posts = await fetch('https://api.example.com/posts').then((r) => r.json());---
<!-- 2. The template. Becomes HTML. Uses { } to embed JS expressions. --><h1>{title}</h1><ul> {posts.map((post) => <Card title={post.title} />)}</ul>The key mental model:
- Everything above the second
---is the component script — it runs once, on the server, at build time (or per request for on-demand pages). This is where you fetch data, import components, and compute values. None of it ships to the browser. - Everything below is the template — HTML with
{ }expressions that drop into JavaScript. This is what becomes the rendered HTML.
flowchart LR
script["--- frontmatter ---
component script
(server only)"] --> compute["fetch data, compute props"]
compute --> template["template (HTML + { } expressions)"]
template --> html["rendered HTML"]Because the script runs on the server, you can await at the top level — fetch from an API or database directly in a component, with no useEffect, no loading state, no client round-trip. The data is already there when the HTML is built.
It’s a superset of HTML
Section titled “It’s a superset of HTML”An .astro template is valid HTML plus JSX-like expressions. If you paste plain HTML into a .astro file, it just works. You add dynamism with { } (expressions), {condition && <div/>} (conditionals), and {items.map(...)} (loops) — the same expression syntax you saw in the landing example. Unlike JSX, you use standard HTML attribute names (class, not className).
astro.config.mjs vs .ts
Section titled “astro.config.mjs vs .ts”The config file can be astro.config.mjs, .js, or .ts — Astro’s config loader resolves any of the three (verified directly in the installed package: astro/dist/core/config/config.d.ts documents resolving astro.config.js|mjs|ts). .mjs is what every scaffold generates (no separate compile step, works immediately), but .ts is fully supported if you want type-checked config code with imports from your own .ts modules — Astro transpiles it on the fly. There’s no functional difference in what you can configure; it’s purely “do you want the file itself type-checked.”
Env files
Section titled “Env files”Astro loads .env (and .env.production, .env.development) via Vite’s built-in env support, and splits variables into two visibility levels by name:
SECRET_KEY=sk_live_abc123PUBLIC_SITE_NAME=MySite- No
PUBLIC_prefix → available in server-side code only (import.meta.env.SECRET_KEY). PUBLIC_prefix → statically inlined into client-side code too (import.meta.env.PUBLIC_SITE_NAME).
Proven with a real build — a <script> (client-side) reading both:
<html><body><script> console.log('secret from client:', import.meta.env.SECRET_KEY); console.log('public from client:', import.meta.env.PUBLIC_SITE_NAME);</script></body></html><!-- the real built <script> content, dist/client/env-test/index.html --><script type="module">console.log(`secret from client:`,void 0),console.log(`public from client:`,`MySite`);</script>The non-PUBLIC_ variable didn’t leak — but it also didn’t error. It was statically replaced with void 0 (undefined) at build time, silently. A generated snippet that reads a secret from client code isn’t insecure by accident here; it’s just broken, and quietly. One more real gotcha: .env files are not loaded inside astro.config.mjs itself — reach for process.env (populated by your shell/CI) if config needs a value at config-evaluation time.
Reading AI-generated code
Section titled “Reading AI-generated code”A generated Astro project layout or config snippet is worth double-checking against the real scaffold above:
- Does it invent a
src/utils/orsrc/lib/convention and call it “required”? Onlysrc/pages/andpublic/are load-bearing; everything else (components/,layouts/,lib/) is convention, not enforced by Astro. - Does it read a non-
PUBLIC_-prefixed env var inside a<script>tag or client component and expect the real value? Proven above: it silently becomesundefinedin the built output, not a leaked secret and not a build error either — a subtle, silent bug. - Does it declare
output: 'hybrid'? That option was removed in Astro v5 —'static'covers what hybrid used to do (see the rendering-modes lesson). - Does it add a
src/fetch.tsfile for something unrelated to routing (afetchwrapper, an API client) without mentioningfetchFile? In Astro 7 that filename is auto-imported for advanced routing and “may cause unexpected errors,” per the upgrade guide quoted above. - Does it use
astro.config.tsand claim.mjs“doesn’t support TypeScript config”? Both are valid entrypoints;.tsjust gets its own file type-checked, it doesn’t unlock anything.mjscan’t configure.
🐛 Spot the bug
---const key = import.meta.env.ANALYTICS_SECRET_KEY;---<script define:vars={{ key }}>fetch('https://analytics.example.com/init', { method: 'POST', body: JSON.stringify({ key }),});</script>Show the answer
The variable name has no PUBLIC_ prefix, so it is server-only by Astro’s rule — but define:vars explicitly serializes the frontmatter value key into the client <script>’s scope. That doesn’t trigger Astro’s client/server split at all: define:vars runs after the value is already resolved on the server, so whatever ANALYTICS_SECRET_KEY actually is gets baked into the shipped HTML in plain text, fully readable via “view source.” Proven with a real build (ANALYTICS_SECRET_KEY=sk_live_shouldnotleak in .env) — the literal secret ends up in dist/client/analytics-test/index.html:
<!DOCTYPE html><script>(function(){const key = "sk_live_shouldnotleak";
fetch('https://analytics.example.com/init', { method: 'POST', body: JSON.stringify({ key }), });})();</script>(Contrast this with reading import.meta.env.ANALYTICS_SECRET_KEY directly inside a <script> block with no define:vars — proven earlier in this lesson, that gets statically replaced with undefined instead, because that form of client script is processed as its own module with only PUBLIC_-prefixed env vars inlined.) The fix: never pass a secret through define:vars; if the client genuinely needs a token, mint a short-lived, scoped one server-side and pass that.
Mental Model
Section titled “Mental Model”A .astro file is two programs glued together by one --- fence: a server program (the frontmatter) that runs once and produces data, and a template program that turns that data into HTML. Nothing in the frontmatter crosses to the client unless you deliberately hand it to a <script> — and even then, only PUBLIC_-prefixed env values and explicit define:vars payloads make that crossing, proven above both ways (one silently becomes undefined, the other leaks in plain text).
Trade-offs
Section titled “Trade-offs”.mjs config |
.ts config |
|
|---|---|---|
| Generated by scaffolds | Yes, always | No — opt-in |
| Type-checking of the config file itself | No | Yes |
Can still import your own .ts modules |
Yes | Yes |
| Extra build step | None | None — Astro transpiles it |
Non-PUBLIC_ env var |
PUBLIC_-prefixed env var |
|
|---|---|---|
| Server-side code | Available | Available |
| Client-side code | Statically replaced with undefined — proven above |
Inlined as its real value |
| Use for | Secrets, database URLs, API keys | Feature flags, public site name, public API base URLs |
Common Misconceptions
Section titled “Common Misconceptions”- “Every Astro project needs a
content.config.ts,middleware.ts, andactions/index.ts.” The real minimal scaffold above has none of them — they’re special filenames Astro recognizes if present, not files it requires. - “
astro.config.tsunlocks config options.mjscan’t do.” Both resolve through the same loader;.tsonly adds type-checking of the config file itself. - “An env var without
PUBLIC_is inaccessible from client code, so referencing it there is caught immediately.” Proven above: it’s not caught at all — it silently becomesundefinedin the built script, which reads as “the feature is broken,” not “the build failed.” - “
.envvalues are available whileastro.config.mjsitself is evaluating.” They’re not —.envloading happens through Vite for your app code, not for the config file; useprocess.envthere instead.