JSON in Astro: fetch, Content Collections, and API Endpoints
Last updated:
Astro fetches JSON in component frontmatter using await fetch(url).then(r => r.json()) — since Astro components run at build time by default, this pre-fetches data and bakes it into HTML with zero client-side JavaScript. For local JSON, import it directly: import data from './data.json' with full TypeScript type inference. Astro's Content Collections let you define a Zod schema (z.object(...)) for your JSON data files in src/content/, then getCollection('blog') returns fully typed entries. For dynamic JSON API routes, create src/pages/api/data.json.ts with an export const GET handler. This guide covers 5 topics: fetch in frontmatter for build-time data, local JSON file imports, Content Collections with Zod schemas, API endpoint JSON responses, and getStaticPaths with JSON-sourced params.
Validate your Astro JSON data
Paste a JSON response or Content Collection entry into Jsonic's validator to catch schema mismatches before they cause build errors.
Open JSON ValidatorFetch JSON in Astro Frontmatter
Bottom line: place await fetch(url).then(r => r.json()) inside the frontmatter fence (---) of any .astro file. Astro components are server-only by default — the fetch runs at build time, the data is inlined into static HTML, and zero JavaScript is sent to the browser. In SSR mode (output: "server"), the same code runs on every request instead.
Always check res.ok before calling res.json(). A 404 or 500 response can still return a JSON error body, but it is not your expected success payload. Wrap in try/catch so a failed fetch causes a build error rather than silently producing an empty page. For parallel fetches, use Promise.all() — build time scales with the slowest sequential fetch, so parallelism matters when you hit multiple endpoints.
---
// src/pages/products.astro
// Frontmatter runs at build time — no client JS shipped
// Basic fetch + parse
const res = await fetch('https://api.example.com/products')
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
const products = await res.json()
// With TypeScript type
interface Product {
id: number
name: string
price: number
}
const typedProducts = await fetch('https://api.example.com/products')
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json<Product[]>() })
// Parallel fetches — faster than sequential
const [featured, categories] = await Promise.all([
fetch('https://api.example.com/featured').then(r => r.json()),
fetch('https://api.example.com/categories').then(r => r.json()),
])
// Error handling: causes a build error if the fetch fails
let posts: Post[] = []
try {
const r = await fetch('https://api.example.com/posts')
if (!r.ok) throw new Error('HTTP ' + r.status)
posts = await r.json()
} catch (err) {
console.error('Failed to fetch posts:', err)
// throw err // uncomment to fail the build on error
}
---
<ul>
{products.map((p: Product) => <li>{p.name} — ${p.price}</li>)}
</ul>Import Local JSON Files
Bottom line: import data from './data.json' works in any .astro, .ts, or .tsx file without any plugin or configuration. Vite (Astro's bundler) handles JSON imports natively. TypeScript infers the full type from the JSON structure — you get autocompletion on every nested field at no extra cost.
The import is statically analyzed at build time. Astro tree-shakes unused top-level properties in some cases, keeping the bundle lean. For JSON files in SSR mode or in server-side .ts files, you can also use Node.js fs.readFileSync + JSON.parse for dynamic paths (paths unknown at compile time), or use import() with assert { type: 'json' } for dynamic imports in environments that support import assertions.
---
// src/pages/about.astro
// Static import — TypeScript infers the type automatically
import config from '../data/site-config.json'
import team from '../data/team.json'
import navItems from '../data/navigation.json'
// config.siteName, config.description, etc. are fully typed
const title = config.siteName // string — autocompletion works
---
<h1>{config.siteName}</h1>
<p>{config.description}</p>
{/* In a .ts utility file */}
// src/lib/get-config.ts
import rawConfig from '../data/config.json'
export interface SiteConfig {
siteName: string
baseUrl: string
features: string[]
}
// Cast if you need a stricter type than inference provides
export const siteConfig = rawConfig as SiteConfig
---
// SSR: dynamic path with fs (when the path is not known at compile time)
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const slug = Astro.params.slug
const raw = readFileSync(join(process.cwd(), 'data', `${slug}.json`), 'utf-8')
const entry = JSON.parse(raw)
---Define Content Collections with Zod
Bottom line: Content Collections are Astro's first-class system for structured content in src/content/. Define a Zod schema in src/content/config.ts, set type: "data" for JSON/YAML files, and Astro validates every file at build time. Invalid data causes a build error with a clear message — no silent runtime mismatches.
getCollection('products') returns an array of entries, each with id (filename without extension), and data (the parsed, validated, fully typed payload). getEntry('products', 'shirt') fetches a single entry by its ID. Because the schema is Zod, you get transforms, defaults, coercions, and union types for free — for example, z.string().url() validates that a field is a well-formed URL, and z.coerce.date() converts an ISO string to a Date object automatically.
// src/content/config.ts
import { defineCollection, z } from 'astro:content'
const productsCollection = defineCollection({
type: 'data', // 'data' = JSON/YAML files; 'content' = Markdown/MDX
schema: z.object({
name: z.string(),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'food']),
tags: z.array(z.string()).default([]),
imageUrl: z.string().url(),
publishedAt: z.coerce.date(), // converts "2026-05-27" string → Date
featured: z.boolean().default(false),
}),
})
const authorsCollection = defineCollection({
type: 'data',
schema: z.object({
name: z.string(),
bio: z.string().optional(),
social: z.object({
twitter: z.string().url().optional(),
github: z.string().url().optional(),
}).default({}),
}),
})
export const collections = {
products: productsCollection,
authors: authorsCollection,
}
---
// src/pages/products.astro
import { getCollection, getEntry } from 'astro:content'
// Fetch all entries — each .json file in src/content/products/ becomes one entry
const products = await getCollection('products')
// products[0].id → "shirt" (filename without extension)
// products[0].data → { name: "T-Shirt", price: 29.99, ... } — fully typed
// Filter at query time
const featured = await getCollection('products', ({ data }) => data.featured)
// Fetch a single entry by ID
const shirt = await getEntry('products', 'shirt')
if (!shirt) throw new Error('Product not found')
const { name, price } = shirt.data // typed as string, number
---Create JSON API Endpoints
Bottom line: create a file under src/pages/ with a .ts extension and export a GET function. The file name determines the route — src/pages/api/products.json.ts becomes /api/products.json. Return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }) or the Response.json(data) shorthand.
By default, Astro pre-renders endpoint files to static JSON files at build time. To serve a live API (responses computed on each request), add export const prerender = false at the top of the file, or set output: "server" globally in astro.config.mjs. Dynamic API routes follow the same [param] file-naming convention as page routes — src/pages/api/products/[id].json.ts gives you /api/products/shirt.json.
// src/pages/api/products.json.ts
import type { APIRoute } from 'astro'
import { getCollection } from 'astro:content'
// Export const prerender = false // uncomment for SSR / live endpoint
export const GET: APIRoute = async ({ request }) => {
const products = await getCollection('products')
const payload = products.map(p => ({
id: p.id,
...p.data,
}))
return new Response(JSON.stringify(payload), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
})
}
// src/pages/api/products/[id].json.ts — dynamic route
import type { APIRoute, GetStaticPaths } from 'astro'
import { getCollection, getEntry } from 'astro:content'
export const getStaticPaths: GetStaticPaths = async () => {
const products = await getCollection('products')
return products.map(p => ({ params: { id: p.id } }))
}
export const GET: APIRoute = async ({ params }) => {
const product = await getEntry('products', params.id!)
if (!product) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
}
return Response.json({ id: product.id, ...product.data })
}
// SSR POST endpoint (requires output: "server" in astro.config.mjs)
export const POST: APIRoute = async ({ request }) => {
const body = await request.json()
if (!body.name) {
return Response.json({ error: 'name is required' }, { status: 400 })
}
// ... save to database
return Response.json({ ok: true, id: crypto.randomUUID() }, { status: 201 })
}Generate Pages from JSON with getStaticPaths
Bottom line: export an async getStaticPaths() function from a dynamic page file (e.g. src/pages/products/[slug].astro). Import or fetch your JSON data inside it, map each item to { params: { slug: item.slug }, props: { product: item } }, and Astro builds one static HTML file per object — no client-side routing involved.
The params object defines the dynamic URL segment values — they must be strings. The props object is passed directly to Astro.props in the component template. For pagination, use Astro's built-in paginate() helper inside getStaticPaths: return paginate(items, { pageSize: 10 }) generates /products/1, /products/2, etc., with a page prop containing data, currentPage, lastPage, and navigation URLs.
---
// src/pages/products/[slug].astro
import type { GetStaticPaths } from 'astro'
import { getCollection } from 'astro:content'
export const getStaticPaths: GetStaticPaths = async () => {
const products = await getCollection('products')
// Map each JSON entry to { params, props }
return products.map(product => ({
params: { slug: product.id }, // → /products/shirt, /products/jacket, ...
props: { product: product.data },
}))
}
// Props are injected from getStaticPaths — no fetch needed in the component
const { product } = Astro.props
---
<h1>{product.name}</h1>
<p>{product.price}</p>
---
// src/pages/products/[...page].astro — paginated variant
import type { GetStaticPaths } from 'astro'
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return paginate(products, { pageSize: 10 })
// Generates /products/1, /products/2, ...
}
const { page } = Astro.props
// page.data → current page items
// page.currentPage → 1, 2, 3 ...
// page.lastPage → total pages
// page.url.next → "/products/2" or undefined
---
<ul>
{page.data.map((p: { name: string; price: number }) => (
<li>{p.name} — {p.price}</li>
))}
</ul>
{page.url.next && <a href={page.url.next}>Next →</a>}JSON in Astro Islands (Client Components)
Bottom line: Astro Islands let you mount interactive React, Vue, Svelte, or Solid components on the client using directives like client:load, client:idle, or client:visible. Pass JSON data as props: <ProductCard product={product} client:load />. Astro serializes the props to JSON automatically and re-hydrates the component on the client with the same data.
Because props are serialized to JSON, they must be JSON-serializable — no functions, class instances, undefined values, or circular references. Pass only the slice of data the island needs; large datasets inflate the inline hydration payload and delay Time to Interactive. For data that changes after page load (live prices, inventory counts), fetch inside the island using useEffect or SWR / TanStack Query rather than passing it as a prop. The client:visible directive defers hydration until the island scrolls into view, which is ideal for below-the-fold components with large JSON props.
---
// src/pages/products/[slug].astro
import { getCollection } from 'astro:content'
import ProductGallery from '../../components/ProductGallery.tsx'
import ReviewsList from '../../components/ReviewsList.svelte'
import LiveStock from '../../components/LiveStock.vue'
export const getStaticPaths = async () => {
const products = await getCollection('products')
return products.map(p => ({ params: { slug: p.id }, props: { product: p.data } }))
}
const { product } = Astro.props
// Fetch reviews at build time (or SSR) — pass as prop to React island
const reviews = await fetch(`https://api.example.com/reviews/${product.id}`)
.then(r => r.json())
---
{/* client:load — hydrates immediately on page load */}
<ProductGallery images={product.images} client:load />
{/* client:visible — hydrates when scrolled into view (lazy) */}
<ReviewsList reviews={reviews} client:visible />
{/* client:idle — hydrates when browser is idle */}
<LiveStock productId={product.id} client:idle />
// src/components/ProductGallery.tsx (React island)
interface Props {
images: Array<{ src: string; alt: string }>
}
export default function ProductGallery({ images }: Props) {
// Props are deserialized from JSON — fully typed
return (
<div className="gallery">
{images.map(img => (
<img key={img.src} src={img.src} alt={img.alt} />
))}
</div>
)
}
// For data that changes after load — fetch inside the island, not via props
import { useState, useEffect } from 'react'
export function LiveInventory({ productId }: { productId: string }) {
const [stock, setStock] = useState<number | null>(null)
useEffect(() => {
fetch(`/api/stock/${productId}.json`)
.then(r => r.json())
.then(d => setStock(d.count))
}, [productId])
return <span>{stock === null ? 'Loading...' : `${stock} in stock`}</span>
}Definitions
- Astro frontmatter
- The JavaScript/TypeScript code block between the two
---fences at the top of an.astrofile. Frontmatter runs on the server (at build time for static output, per request for SSR) and is never sent to the browser. It is the correct place forfetch()calls, imports, and data transformations. - Content Collections
- Astro's built-in API for managing structured content files in
src/content/. Collections are defined with a Zod schema insrc/content/config.ts. Thetype: "data"option handles JSON and YAML files;type: "content"handles Markdown and MDX. Validation runs at build time, catching schema errors before deployment. - Astro Islands
- Interactive UI components (React, Vue, Svelte, Solid, etc.) embedded in otherwise static Astro pages. Islands are hydrated on the client using directives:
client:load,client:idle,client:visible,client:media. The rest of the page ships zero JavaScript. getStaticPaths()- An async function exported from dynamic Astro page files (files with
[param]in their name). It returns an array of{ params, props }objects. Astro calls it at build time and generates one static HTML file per entry. Props are available asAstro.propsin the component. - Zod schema
- A TypeScript-first schema declaration library used by Astro's Content Collections. A
z.object({ name: z.string(), price: z.number() })schema validates that each JSON file has anamestring and a numericprice, and infers the TypeScript type automatically. Invalid files cause a build error with the file path and field name highlighted. - API endpoint (Astro)
- A
.tsfile undersrc/pages/that exports HTTP verb handlers (GET,POST, etc.) returningResponseobjects. The route is derived from the file path. By default, endpoints are pre-rendered to static files; addingexport const prerender = falsemakes them server-rendered. getCollection()/getEntry()- Astro Content Collection query functions imported from
astro:content.getCollection('name')returns all entries in a collection, optionally filtered by a callback.getEntry('name', 'id')returns a single entry by its file-name-derived ID. Both return fully typed, Zod-validated data.
FAQ
How do I fetch JSON data in an Astro component?
Use await fetch(url).then(r => r.json()) inside the frontmatter fence (---). The fetch runs at build time for static output — the result is baked into HTML with no client-side JS. Always check res.ok before .json(), and wrap in try/catch to make a failed fetch a build error. For parallel fetches, use Promise.all().
How do I import a JSON file in Astro?
Use import data from '../data/config.json' in frontmatter or in any .ts / .tsx file. Vite handles JSON imports natively — no plugin needed. TypeScript infers the full type from the JSON structure, giving you autocompletion on every field. The import is resolved at build time and the data is inlined into the output.
What are Astro Content Collections and how do they use JSON?
Content Collections are Astro's system for structured content in src/content/. Define a Zod schema with defineCollection({ type: 'data', schema: z.object({...}) }) in src/content/config.ts. Every JSON file in the collection is validated at build time — invalid data causes a build error with a clear message. Use getCollection('name') to fetch all entries and getEntry('name', 'id') for a single entry.
How do I create a JSON API endpoint in Astro?
Create src/pages/api/data.json.ts and export a GET: APIRoute function that returns a Response. Use Response.json(payload) or new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }). For live (non-pre-rendered) endpoints, add export const prerender = false at the top of the file, or set output: "server" globally.
How do I use JSON data with getStaticPaths in Astro?
Export getStaticPaths() from a dynamic page like src/pages/products/[slug].astro. Inside, fetch or import your JSON data and return items.map(item => ({ params: { slug: item.id }, props: { product: item } })). Astro generates one static HTML file per entry. Access props in the template via Astro.props.
Can I use JSON in Astro client-side components?
Yes. Pass JSON-serializable data as props to any island component: <MyComponent data={data} client:load />. Astro serializes the props and re-hydrates the component on the client. Props must be JSON-serializable (no functions, class instances, or circular references). For data that changes after load, fetch inside the component using useEffect or a data-fetching library instead of using props.
Further reading and primary sources
- Astro Content Collections — Official Astro guide to defining, validating, and querying typed content collections with Zod schemas for JSON and Markdown files
- Astro API endpoints — Official reference for creating static and server-rendered API endpoints in .ts files, covering GET/POST handlers and Response.json()
- Astro data fetching — Official Astro guide to fetching JSON from external APIs in frontmatter, including build-time and SSR patterns
- Astro getStaticPaths — API reference for getStaticPaths() including params, props, and the paginate() helper for JSON-sourced dynamic routes
- Astro TypeScript — Official Astro TypeScript guide covering tsconfig strictness, JSON import types, and typing Content Collection schemas
Validate your Astro JSON data
Paste a JSON response or Content Collection entry into Jsonic's validator to catch schema mismatches before they cause build errors.
Open JSON Validator