
How to fetch data at build time in Astro?
To fetch data at build time in Astro, call fetch() with await in the frontmatter script of any .astro page or component. Astro supports top-level await, so const data = await (await fetch("https://api.example.com/items")).json() is all you need, and the result can be rendered in the template or passed to other components as props. On a static site, which is Astro's default, that code runs once during astro build and the data is baked into the generated HTML, so visitors never call the API themselves. To create one page per item, fetch inside getStaticPaths() and return a path for each result.
Build-time fetching is one of Astro's simplest features, but it raises practical questions: how to avoid fetching the same data for every page, what happens when an API fails mid-build, how to keep tokens secret, and how to get fresh data without a rebuild. In this article you'll learn the basic pattern, parallel requests, generating pages from API data, caching requests across pages, error handling and retries, GraphQL queries, environment variables, and when to use a content loader or on-demand rendering instead. Every example was run in an Astro 7.4 project against a public test API.
The Basic Pattern
Any Astro component can fetch data in its script, between the --- fences:
---
// src/pages/team.astro
const response = await fetch("https://jsonplaceholder.typicode.com/users");
const users: Array<{ id: number; name: string; email: string }> = await response.json();
---
<h1>Team</h1>
<ul>
{users.map((user) => (
<li>{user.name} ({user.email})</li>
))}
</ul>
There's nothing to install. fetch() is the standard global available in Node, which Astro 7 requires at version 22.12 or later. When you run astro build, Astro executes this script, waits for the request, renders the HTML and writes dist/team/index.html. The deployed page is plain HTML with the names already in it.
In development the behaviour differs: astro dev runs the script on every request, so you'll see fresh data each time you reload. That's handy while building, but it also means a rate-limited API can be hit a lot during development.
Where the Code Runs
The component script always runs on the server or at build time, never in the browser. That has two useful consequences:
- No CORS issues: the request comes from Node, not from a browser, so the API doesn't need CORS headers.
- No client JavaScript: the fetching code isn't shipped to visitors. Only the resulting HTML is.
If you need data that updates in the browser after the page loads, that's client-side fetching, done in a <script> tag or a framework component with a client:* directive.
Fetching in Parallel
Awaiting requests one after another adds their durations together. When requests don't depend on each other, start them all at once with Promise.all():
---
// src/pages/dashboard.astro
const API = "https://jsonplaceholder.typicode.com";
const [posts, todos] = await Promise.all([
fetch(`${API}/posts`).then((r) => r.json()),
fetch(`${API}/todos`).then((r) => r.json()),
]);
const done = todos.filter((t: { completed: boolean }) => t.completed).length;
const builtAt = new Date().toISOString();
---
<p>{posts.length} posts, {done} of {todos.length} todos done.</p>
<p>Built at {builtAt}</p>
The built page contained:
<p>100 posts, 90 of 200 todos done.</p><p>Built at 2026-10-24T05:03:57.601Z</p>
The "Built at" line makes the build-time nature obvious. That timestamp stays the same for every visitor until the next build, which is exactly how all fetched data on a static page behaves.
Generating Pages From API Data
To create a page for each item an API returns, fetch inside getStaticPaths() in a dynamic route:
---
// src/pages/users/[id].astro
import type { GetStaticPaths } from "astro";
import { getUsers } from "../../lib/api";
export const getStaticPaths = (async () => {
const users = await getUsers();
return users.map((user) => ({
params: { id: String(user.id) },
props: { user },
}));
}) satisfies GetStaticPaths;
const { user } = Astro.props;
---
<h1>{user.name}</h1>
<p>{user.email} · {user.company.name}</p>
A few rules to remember:
- Params must be strings: convert numeric IDs with
String(). - Pass data as props: the page receives each item through
Astro.props, so it doesn't need to fetch again. - getStaticPaths() runs in isolation: it can't use variables from the rest of the component script, so imports and helpers must be available at module level. Since Astro 6, reading
Astro.siteorAstro.generatorinsidegetStaticPaths()logs a deprecation warning, and accessing any otherAstroproperty there throws an error.
This built ten pages, /users/1/ to /users/10/, and /users/3/ contained the third user's details:
<h1>Clementine Bauch</h1><p>Nathan@yesenia.net · Romaguera-Jacobson</p>
Dynamic routes are covered in depth in dynamic routes with getStaticPaths.
Avoiding Duplicate Requests
A common setup is a list page and a detail route that both need the same data. Fetching in each one means at least two identical requests per build, more if several components need it. A small module-level cache fixes that:
// src/lib/api.ts
const API = "https://jsonplaceholder.typicode.com";
export interface User {
id: number;
name: string;
email: string;
company: { name: string };
}
let usersPromise: Promise<User[]> | undefined;
export function getUsers(): Promise<User[]> {
usersPromise ??= (async () => {
console.log("[api] fetching /users");
const response = await fetch(`${API}/users`);
if (!response.ok) {
throw new Error(`GET /users failed: ${response.status} ${response.statusText}`);
}
return (await response.json()) as User[];
})();
return usersPromise;
}
The first call starts the request and stores the promise. Every later call gets the same promise, so concurrent callers share one request instead of racing to start their own. Both the index page and the [id] route above import getUsers(), and the build log showed a single request for all eleven pages:
[api] fetching /users
├─ /users/1/index.html
├─ /users/2/index.html
...
├─ /users/index.html
This cache lives for one build only. Don't use it on pages rendered on demand, where a module-level variable would keep serving the first response for as long as the server runs.
Handling Errors
What should happen when an API fails during a build? Usually the build should fail, so you don't deploy a page full of missing data. Check response.ok, because fetch() only rejects on network errors, not on 404 or 500 responses:
// src/lib/fetch-json.ts
export async function fetchJson<T>(url: string, init: RequestInit = {}, retries = 2): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`${init.method ?? "GET"} ${url} failed: ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
if (attempt >= retries) throw error;
console.warn(`Retrying ${url} (${attempt + 1}/${retries})`);
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
}
}
This helper adds three things the bare fetch() lacks:
- A status check: non-2xx responses become errors.
- A timeout:
AbortSignal.timeout()stops a hanging request from stalling the build forever. - Retries with backoff: transient failures get two more chances, half a second and then a second apart.
Pointed at a URL that returns 404, it retried twice and then the build stopped with exit code 1:
Retrying https://jsonplaceholder.typicode.com/nope (1/2)
Retrying https://jsonplaceholder.typicode.com/nope (2/2)
[ERROR] [build] Caught error rendering /broken: Error: GET https://jsonplaceholder.typicode.com/nope failed: 404
Your previous deployment stays online, and you can rebuild once the API is back. Retrying a 404 is pointless in real code, so in production you might only retry on network errors and 5xx statuses.
If a piece of data is optional, such as a "latest tweets" widget, catch the error in that component and render a fallback instead of failing the whole build.
Querying a GraphQL API
GraphQL is just a POST request with a JSON body, so fetch() handles it directly:
---
// src/components/Film.astro
const response = await fetch(
"https://swapi-graphql.netlify.app/.netlify/functions/index",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `
query getFilm($id: ID!) {
film(id: $id) {
title
releaseDate
}
}
`,
variables: { id: "ZmlsbXM6MQ==" },
}),
},
);
const { data } = await response.json();
---
<h2>{data.film.title}</h2>
<p>Released {data.film.releaseDate}</p>
This example is adapted from the Astro data fetching guide. GraphQL servers often return HTTP 200 even when a query fails, with the problem described in an errors array, so check for errors in the JSON as well as response.ok.
Using API Keys and Secrets
Private APIs need tokens. Put them in a .env file, which Astro loads automatically, and read them with import.meta.env:
# .env
CMS_TOKEN=your-secret-token
---
// src/pages/products.astro
import { fetchJson } from "../lib/fetch-json";
const products = await fetchJson<Array<{ id: string; name: string }>>(
"https://api.example.com/products",
{ headers: { Authorization: `Bearer ${import.meta.env.CMS_TOKEN}` } },
);
---
Variables without the PUBLIC_ prefix are only available in server code, which includes component scripts at build time. In my test, import.meta.env.CMS_TOKEN was readable in the page script, and the token value didn't appear anywhere in the generated HTML. Don't render the token in the template, though, or it will be baked into the page like any other value.
Add .env to .gitignore and set the same variable in your host's build settings. For type-safe, validated variables, Astro's astro:env module is worth a look; see how to use environment variables in Astro.
Fetching in Framework Components
fetch() also works at the top level of framework components, such as React or Preact components. When those components render on the server without a client:* directive, the fetch runs at build time just like in an .astro file. With a client directive, any fetch code in the component can also run in the browser. In most cases it's cleaner to fetch in the Astro page and pass the data down as props, so the framework component stays a pure view.
Page-Level Fetch vs Content Loaders
For one-off data, a fetch in the page is simplest. When data becomes content, a remote content loader often fits better:
fetch() in a page | Content collection loader | |
|---|---|---|
| Setup | None | A loader in src/content.config.ts |
| Validation | Manual | Zod schema, typed entries |
| Shared across pages | Needs your own cache | Built in, query with getCollection() |
| Persists between builds | No | Yes, in the data store |
| Markdown rendering | Manual | renderMarkdown() and <Content /> |
| Best for | Small, page-specific data | Blog posts, products, anything reused |
A useful rule: if more than two pages need the data, or it has a body you render, move it into a collection.
Keeping Data Fresh
Build-time data is only as fresh as your last build. You have several options:
- Rebuild on change: most CMSs and headless services can call a deploy hook on your host when content is published.
- Schedule builds: an hourly or nightly scheduled build suits APIs without webhooks.
- Render on demand: with an adapter, add
export const prerender = falseto a page and itsfetch()runs on every request instead of at build time. - Fetch in the browser: for truly live widgets, fetch client-side after the page loads.
Option 3 changes the trade-off completely: every visitor waits for the API, and you'll want caching. Static vs on-demand rendering explains how to choose, and caching on-demand pages covers how to keep them fast.
FAQ: Fetching Data at Build Time in Astro
No. Code in the component script runs at build time for static pages, or on the server for pages rendered on demand. Only the resulting HTML is sent to the browser, along with any client scripts you add explicitly.
Only when the site is rebuilt. Every visitor sees the data as it was during the last build. Use deploy hooks, scheduled builds or on-demand rendering if you need fresher data.
Yes. Astro component scripts support top-level await, so you can await fetch() and response.json() directly in the frontmatter without wrapping them in an async function.
Put the request in a shared module and cache the promise in a module-level variable, so every page that calls the function during the build reuses the same request. Or load the data into a content collection.
If the error is thrown and not caught, the build fails and your current deployment stays online. Check response.ok, since fetch() doesn't reject on HTTP error statuses, and catch errors only for optional data.
Yes, as long as the key is read from a server-only variable like import.meta.env.CMS_TOKEN and never rendered in the template. The fetch code itself isn't shipped to the browser.
Conclusion
Fetching data at build time in Astro is just await fetch() in a component script. On a static site it runs once during the build, the results are rendered into HTML, and visitors get fast pages with no API calls and no client JavaScript. Use Promise.all() for independent requests, getStaticPaths() to turn API results into pages, and a cached promise in a shared module so several pages don't repeat the same request.
Treat failures deliberately: check response.ok, add a timeout, retry transient errors and let the build fail when essential data is missing. Keep tokens in server-only environment variables, and when data is reused widely or needs validation, move it into a content collection loader. When build-time freshness isn't enough, deploy hooks, scheduled builds or on-demand rendering take over.


