Data Fetching
Astro lets you fetch data exactly where you need it: in the component script fence at the top of any .astro file. Because that script runs on the server (never in the browser), you can call APIs, read databases, and use secrets without shipping a single byte of JavaScript to the client. The same fetch call can run once at build time to pre-render static HTML, or on every request when the route is server-rendered — the only thing that changes is your output mode.
Where data fetching happens
Every .astro component has a code fence delimited by ---. Anything you write there executes on the server during rendering and is fully scoped to that component. Astro supports top-level await, so you can fetch data directly without wrapping it in an async function or a useEffect-style hook.
---
const response = await fetch("https://api.example.com/products");
const products = await response.json();
---
<ul>
{products.map((product) => (
<li>{product.name} — ${product.price}</li>
))}
</ul>
The fetch here is the standard Web Platform fetch, available globally with no import. The result is interpolated into HTML on the server, so the browser only ever receives finished markup — zero JavaScript, zero client-side loading spinners.
Data fetched in the component script is not available to client-side
<script>tags or to framework islands automatically. The script fence runs on the server only. To hand data to the browser, pass it as props to a hydrated island or serialize it into the page.
Build time vs. request time
When the fetch runs depends on your render mode, not your code. The same component behaves differently based on output in astro.config.mjs and whether the page is prerendered.
| Mode | When fetch runs | Best for |
|---|---|---|
| Static (SSG) | Once, at astro build | Blog posts, marketing pages, docs — data that changes rarely |
| On-demand (SSR) | On every incoming request | Dashboards, personalized pages, live data |
| Prerendered route in SSR | Once, at build | Mixing static pages into a mostly-dynamic site |
Build time (static)
By default Astro is a static site generator. Each page’s script fence runs once when you run astro build, and the resulting HTML is written to disk. This is ideal for content that is the same for every visitor.
npm run build
Output:
12:04:18 [build] 142 page(s) built in 3.21s
12:04:18 [build] Complete!
If you fetch from a CMS or a public API at build time, your live site serves pure static files — fast, cacheable, and resilient even if the upstream API is later down.
Request time (server-rendered)
To run the fetch per request, opt the route into on-demand rendering. In Astro 5 you do this per page with export const prerender = false (after adding a server adapter), or globally via output: "server".
---
export const prerender = false;
const userId = Astro.params.id;
const response = await fetch(`https://api.example.com/users/${userId}`);
const user = await response.json();
---
<h1>Welcome back, {user.name}</h1>
<p>Last seen: {new Date(user.lastSeen).toLocaleString()}</p>
Now the fetch executes on each visit, so the page always reflects current data and can read per-request context like Astro.params, Astro.request, and cookies.
Handling errors and missing data
Network calls fail. Because the fence is just server-side JavaScript, you handle errors with ordinary try/catch and can return a 404 via Astro.redirect or by throwing a Response.
---
let posts = [];
try {
const response = await fetch("https://api.example.com/posts");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
posts = await response.json();
} catch (error) {
console.error("Could not load posts", error);
}
---
{posts.length > 0 ? (
<ul>{posts.map((p) => <li>{p.title}</li>)}</ul>
) : (
<p>No posts available right now.</p>
)}
Typing your data
Pair fetch with TypeScript interfaces so the rest of the template is type-safe. Define the shape, then assert it on the parsed JSON.
export interface Product {
id: number;
name: string;
price: number;
}
export async function getProducts(): Promise<Product[]> {
const response = await fetch("https://api.example.com/products");
return response.json() as Promise<Product[]>;
}
Import the helper into any component’s fence and the returned array carries full editor autocompletion and compile-time checks.
Best practices
- Fetch at build time whenever the data can be stale until the next deploy — it produces the fastest, most reliable pages.
- Reserve request-time fetching for genuinely dynamic or personalized data, and add a server adapter before setting
prerender = false. - Keep secrets in environment variables (
import.meta.env) since the fence never reaches the browser. - Always check
response.okand wrap calls intry/catchso a flaky API doesn’t break your build or your page. - Extract repeated fetch logic into typed helper functions in a
src/libdirectory rather than duplicating it across components. - Pass only the data each island actually needs as props — avoid serializing large payloads into the HTML.