Data Fetching
Fetching data is just top-level await
Section titled “Fetching data is just top-level await”An .astro component script runs on the server, so you fetch external data the plainest way possible: await fetch() at the top level. No useEffect, no loading state, no client round-trip — the data is resolved before the HTML exists.
---const res = await fetch('https://api.example.com/products');if (!res.ok) throw new Error(`API returned ${res.status}`);const products: { id: string; name: string; price: number }[] = await res.json();---<ul> {products.map((p) => <li>{p.name} — ${p.price}</li>)}</ul>The same pattern works against a REST API, a database client, or a headless CMS SDK. Because it runs on the server, secrets (API keys) stay server-side and never ship to the browser.
Build-time vs request-time: the crucial distinction
Section titled “Build-time vs request-time: the crucial distinction”When that fetch runs depends on the page’s rendering mode — and this is the single most important thing to understand about data in Astro:
- Static page (the default). The
fetchruns once, at build time. The response is baked into the HTML file. Every visitor gets the same snapshot until you rebuild. Perfect for a product list, a blog index, docs — data that changes on a deploy cadence. - On-demand page (
prerender = false). Thefetchruns per request, on the server. Each visitor can get fresh, personalized data — the current user, live inventory, a search result. Requires an adapter (see the rendering module).
---// This page renders per request, so the fetch is live on every visit.export const prerender = false;
const res = await fetch('https://api.example.com/inventory');const inventory = await res.json();---<p>{inventory.inStock} in stock right now</p>The mental test: does this data need to be fresh per visitor, or is a build-time snapshot fine? Snapshot → leave it static (fast, cacheable). Fresh → opt the route into on-demand rendering. Reaching for on-demand when a static snapshot would do is the most common way people give up Astro’s speed for no reason.
flowchart TB q["Does data change per request?"] q -->|no| static["Static: fetch runs once at build, baked into HTML"] q -->|yes| ondemand["On-demand: prerender = false, fetch runs per request"] static --> cdn["served from CDN, same for everyone"] ondemand --> server["rendered on the server per visitor"]
A third option: live content collections
Section titled “A third option: live content collections”Static-vs-on-demand isn’t quite the whole picture. Live content collections (added in Astro 7.0) give request-time data the same typed, validated shape a regular collection gives build-time content — a middle path between “raw fetch on-demand” and “collection baked at build.”
They live in a separate config file, src/live.config.ts, defined with defineLiveCollection() instead of defineCollection():
import { defineLiveCollection } from 'astro:content';import { productsLoader } from './lib/products-loader';
const products = defineLiveCollection({ loader: productsLoader() });
export const collections = { products };A live loader is a different shape from a regular loader — instead of one load() that fills a store once, it implements loadCollection and loadEntry, each called fresh on every request:
import type { LiveLoader } from 'astro/loaders';
interface Product { id: string; name: string; price: number }
const CATALOG: Product[] = [ { id: 'sku-1', name: 'Sticker Pack', price: 5 }, { id: 'sku-2', name: 'Mug', price: 12 },];
// A tiny in-memory live loader: proves loadCollection/loadEntry run per request,// not at build time (no filesystem/network involved).export function productsLoader(): LiveLoader<Product, { id: string }> { return { name: 'products-loader', loadCollection: async () => { return { entries: CATALOG.map((p) => ({ id: p.id, data: p })) }; }, loadEntry: async ({ filter }) => { const product = CATALOG.find((p) => p.id === filter.id); if (!product) return { error: new Error(`Product ${filter.id} not found`) }; return { id: product.id, data: product }; }, };}You query with getLiveCollection/getLiveEntry (from astro:content, alongside the build-time getCollection/getEntry) — the result is a { data, error } shape instead of a thrown exception, so a failed fetch is something you handle, not something that crashes the page:
---// src/pages/shop/[id].astro — requires an adapter (on-demand rendering)export const prerender = false; // not needed if the whole project is output: 'server'import { getLiveEntry } from 'astro:content';
if (!Astro.params.id) return Astro.redirect('/404');
const { entry, error } = await getLiveEntry('products', Astro.params.id);if (error || !entry) return new Response(error?.message ?? 'Not found', { status: 404 });---<p>{entry.data.name}: ${entry.data.price}</p>Proven end-to-end in the probe — built with the Node adapter and hit with real curl requests, getLiveCollection/getLiveEntry return live data and the loader’s own “not found” error on a bad id:
$ curl http://127.0.0.1:4501/shop/<!DOCTYPE html><ul><li>Sticker Pack — $5</li><li>Mug — $12</li></ul>$ curl http://127.0.0.1:4501/shop/sku-1<!DOCTYPE html><p>Sticker Pack: $5</p>$ curl -w " [status %{http_code}]" http://127.0.0.1:4501/shop/nopeProduct nope not found [status 404]Beyond your own loader’s errors, getLiveCollection/getLiveEntry can also return a LiveEntryNotFoundError (no matching entry), a LiveCollectionValidationError (data failed an optional schema on the live collection), or a LiveCollectionCacheHintError — check with instanceof when you need to branch on which one happened.
Live collections need an adapter, the same as any on-demand rendering — they run loadCollection/loadEntry at request time, so a purely static (output: 'static', no adapter) build has nowhere to run them per request. That said, Astro does not refuse to build a page that calls getLiveCollection if that page itself ends up prerendered (either the whole project is static, or the page sets prerender = true): it just runs the loader once, at build time, like any other data source — silently giving up the “live” guarantee with no warning. Proven in the probe: an otherwise-identical src/pages/shop/prerendered.astro with export const prerender = true built cleanly and baked the catalog into static HTML at build time, not per request. A live collection only stays live on a route that actually renders on demand.
Fetch vs. a live collection vs. a regular collection
Section titled “Fetch vs. a live collection vs. a regular collection”Three tools now answer “where does this data come from,” and they’re easy to reach for out of habit rather than fit:
fetchdirectly — the least structure. No schema, no typed entries; you parse and validate the response yourself. Fine for a one-off call you don’t reuse.- A content collection (
getCollection/getEntry) — build-time, schema-validated, typed. The right choice for content that changes on a deploy cadence. - A live collection (
getLiveCollection/getLiveEntry) — request-time, same typed/validated shape as a collection, explicit{ data, error }handling. The right choice for per-visitor or frequently-changing data you still want collection-like structure and validation for, not just a rawfetch.
Headless CMS
Section titled “Headless CMS”A headless CMS (Contentful, Sanity, Storyblok, a WordPress API) is just another data source. Fetch it in the component script — often via the CMS’s SDK — and map the response into your template. For content you own and edit as files, prefer a content collection with a loader; for content managed in an external system, fetch it (or use a community loader that wraps the CMS behind the collections API). Either way the page code looks the same: get data on the server, render HTML.
What about Astro.glob()?
Section titled “What about Astro.glob()?”Older Astro code used Astro.glob() to pull in many local files (all Markdown in a folder) at once. Astro 6 removed it entirely (source: Astro v6 upgrade guide) — its replacement is content collections, which give you the same “load many files” power plus schema validation and generated types (for a non-content use case that still needs raw file globbing, use Vite’s import.meta.glob() instead). For local content, define a collection with a glob loader; reserve raw fetch for genuinely external data.
Proven in the probe on Astro 7.3.5 — Astro.glob doesn’t exist on the global at all anymore, so astro check catches it at the type level, before a build ever runs:
error ts(2339): Property 'glob' does not exist on type'Readonly<AstroGlobal<Record<string, any>, AstroComponentFactory, Record<string, string | undefined>>>'.
const posts = await Astro.glob('../../data/blog/*.md'); ~~~~Mental Model
Section titled “Mental Model”Three different rendering timelines answer three different questions, and mixing them up is the most common data-fetching mistake in Astro. A plain fetch and a content collection both resolve once, ahead of the response — the collection just does it with validation and types on top. A live collection resolves fresh on every request — but that only happens if the route it’s called from actually renders on demand; call it from a route that gets prerendered anyway (by project config or a page-level prerender = true) and it quietly collapses into “resolved once,” same as the other two, with no error to tell you your “live” data went stale at build time.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Raw fetch |
No schema to write, works anywhere | You validate and type the response yourself; no reuse across pages without your own helper |
| A content collection | Schema-validated, typed, generated at build | Snapshot only — never fresher than the last build, whatever the visitor needs |
| A live collection | Same typed/validated shape as a collection, but resolved per request | Needs an adapter and an actually on-demand route — silently degrades to build-time if the route is prerendered |
{ data, error } from getLiveCollection/getLiveEntry |
Failure is a value you branch on, not an exception that crashes the page | Every call site must remember to check error — forgetting reads undefined data instead of failing loudly |
Common Misconceptions
Section titled “Common Misconceptions”- “Live collections are just fetch with extra steps.” A live collection gives you the same schema validation and typed
dataa build-time collection gives you —fetchgives you neither unless you add it yourself. The “live” part is when it runs, not whether it’s validated. - “A live collection is always fresh, wherever I call it from.” It’s only fresh on a route that renders on demand. On a prerendered route (project-wide
output: 'static', or a page withprerender = true), Astro runs the loader once at build time with no warning — the page still works, it just isn’t live anymore. - “
getLiveEntrythrows like a normal async function when the entry is missing.” It returns{ error }instead of throwing — code that only checksif (entry)without checkingerrorfirst will readundefinedoff a successful-looking call. - “
Astro.glob()still works, it’s just discouraged.” It was deprecated in Astro 5 and is fully removed in Astro 6/7 — calling it isn’t a style choice, it’s a type error (Property 'glob' does not exist) thatastro checkcatches before you even build.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet fetches data — with fetch, a collection, or a live collection — check for these before trusting it:
- Does it call
Astro.glob(...)? Training data from before Astro 6 still produces this constantly — it no longer exists; replace it with a content collection (orimport.meta.globfor a non-content use case). - Does a call to
getLiveCollection/getLiveEntrycheck the returnederrorbefore usingdata/entries, or does it assume success the way afetch().then()chain might? - Is a live collection’s loader (or the page calling it) missing an adapter/on-demand setup? It won’t error — it’ll just quietly resolve once at build time, defeating the purpose with no warning.
- Does a page that genuinely needs per-visitor freshness rely on a plain build-time
getCollectioncall, when the data actually changes faster than the deploy cadence? - Does a secret (an API key, a token) used in a top-level
fetchever get passed into a client-side script or component prop, instead of staying inside the server-only component script?
🐛 Spot the bug
---const posts = await Astro.glob('../data/blog/*.md');---<ul>{posts.map((p) => <li>{p.frontmatter.title}</li>)}</ul>Show the answer
Astro.glob() was deprecated in Astro 5 and removed entirely in Astro 6 — it no longer exists on the Astro global at all. Proven in the probe on Astro 7.3.5: astro check fails at the type level with Property 'glob' does not exist on type 'Readonly<AstroGlobal<...>>', so this never even reaches a build — it’s a compile-time error, not a runtime surprise. The fix is a content collection: define one with a glob loader in src/content.config.ts and query it with getCollection('blog'), which additionally validates and types every entry instead of handing back untyped frontmatter.