Fetching in the Component Script
Every .astro file begins with a component script fence — the block delimited by triple dashes (---) at the top of the file. This code runs on the server (or at build time), once per request or per build, before any HTML is generated. Because the fence is treated as a top-level module body, you can use await directly there. That makes it the natural place to fetch data so the page ships fully rendered HTML with zero client-side JavaScript by default.
Why fetch in the fence
Astro is server-first. The component script never reaches the browser, so anything you fetch there stays on the server: API keys, database connections, and large payloads all stay out of the client bundle. By the time the template renders, your data is a plain JavaScript value you can interpolate into HTML.
This is the opposite of the typical SPA pattern where the browser downloads JS, mounts a component, then fetches data and shows a spinner. With Astro, the work happens first and the visitor receives finished markup.
Tip: Code in the fence runs at build time for statically generated pages and per-request for pages rendered on demand (SSR). The same
fetch()call works in both modes — only when it runs changes.
Top-level await
Because the fence is an ES module, top-level await is supported with no wrapper function:
---
const response = await fetch("https://api.github.com/repos/withastro/astro");
const repo = await response.json();
---
<article>
<h1>{repo.full_name}</h1>
<p>{repo.description}</p>
<p>⭐ {repo.stargazers_count.toLocaleString()} stars</p>
</article>
Astro polyfills fetch() globally, so you can call it in the fence even on Node versions where it would otherwise be unavailable, and you do not need to import it.
Handling errors and statuses
fetch() only rejects on network failures, not on HTTP error codes. Always check response.ok and decide how to fail. In SSR you can return a real HTTP status from the page itself.
---
const response = await fetch("https://api.example.com/products");
if (!response.ok) {
return new Response("Failed to load products", { status: 502 });
}
const products = await response.json();
---
<ul>
{products.map((p) => <li>{p.name} — ${p.price}</li>)}
</ul>
For static builds, an unhandled rejection in the fence will fail the build for that page, which is often what you want — a broken data source should not produce a silently empty page.
Typing the response
In .astro files you can use TypeScript in the fence. Annotate the parsed JSON so the template gets autocomplete and type checking.
---
interface Post {
id: number;
title: string;
body: string;
}
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=3");
const posts: Post[] = await res.json();
---
<section>
{posts.map((post) => (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
</article>
))}
</section>
Reading local files and databases
The fence is server code, so you are not limited to HTTP. You can read the filesystem or query a database directly using standard Node and library APIs.
---
import { readFile } from "node:fs/promises";
const raw = await readFile(new URL("../data/team.json", import.meta.url), "utf-8");
const team = JSON.parse(raw);
---
<ul>
{team.map((member) => <li>{member.name}</li>)}
</ul>
The same pattern applies to a database client — open the connection, await the query, and render the result. None of the connection logic is exposed to the browser.
Where fetching happens
| Render mode | When the fence runs | Data freshness |
|---|---|---|
| Static (default) | At build time | Frozen until the next build |
SSR (output set) | On every request | Fresh per request |
| Prerendered route | At build time (opt-in) | Frozen, even in an SSR project |
A small environment check lets you see the difference:
---
const builtAt = new Date().toISOString();
console.log(`Rendering at ${builtAt} (import.meta.env.SSR=${import.meta.env.SSR})`);
---
<p>Generated: {builtAt}</p>
Output:
Rendering at 2026-06-14T09:12:44.301Z (import.meta.env.SSR=false)
Warning: In a static build,
new Date()is evaluated once at build time, not on each visit. If you need a per-visit value, switch the route to on-demand rendering.
Best practices
- Check
response.okand handle non-2xx statuses explicitly;fetch()will not throw on a 404 or 500. - Keep secrets in the fence — read API keys from
import.meta.envand never pass them to client components. - Prefer top-level
awaitin the fence over fetching inside client-side scripts when the data is needed for the initial render. - Type your parsed JSON with an
interfaceso the template is type-safe. - Run independent requests concurrently with
Promise.all([...])instead of awaiting them one after another. - Choose static vs. SSR per route based on how fresh the data must be, and prerender routes whose data rarely changes.