Full-stack frameworks
Astro
Add an API endpoint to Astro and call it from a page or browser island.
On this page
Serve your API from an Astro endpoint, then call it from the browser or an Astro component. The example adds a route at /api and checks it with an HTTP request.
This is useful when other apps also need your API. For a form or operation used only by your Astro site, Astro Actions may be all you need.
This guide assumes an Astro application with Node.js 22 or newer. @hulla/api-astro supports Astro
5, 6, and 7.
@hulla/api-astrodoes not deploy an Astro application. An on-demand endpoint or deferred server island also needs an Astro deployment adapter such as@astrojs/node,@astrojs/cloudflare, or the adapter for your host.
bun add @hulla/api-astro @hulla/api zodnpm install @hulla/api-astro @hulla/api zodpnpm add @hulla/api-astro @hulla/api zodyarn add @hulla/api-astro @hulla/api zodKeep the contract in a shared module and the implementation in a server-only module. This example
uses one users.byId route throughout the page.
// src/api/contract.ts
import { defineContract, response, route, router } from '@hulla/api'
import { z } from 'zod'
const user = z.object({ id: z.string(), name: z.string() })
export const contract = defineContract({
basePath: '/api',
routes: {
users: router('/users', {
routes: {
byId: route.get('/:id', {
params: z.object({ id: z.string() }),
responses: {
200: response.json(user),
404: response.json(z.object({ message: z.string() })),
},
}),
},
}),
},
})// src/api/server.ts — server-only code
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'
const users = new Map([['ada', 'Ada Lovelace']])
export const implementation = defineServer(contract).implement({
users: {
byId: ({ params, response }) => {
const name = users.get(params.id)
return name === undefined
? response(404, { message: 'User not found' })
: response(200, { id: params.id, name })
},
},
})Mount that implementation from an Astro rest-parameter endpoint:
// src/pages/api/[...api].ts
import { astroAdapter } from '@hulla/api-astro'
import { implementation } from '../../api/server'
export const prerender = false
export const ALL = astroAdapter().mount(implementation)The contract’s basePath (/api) must align with the Astro file route
(src/pages/api/[...api].ts). The rest parameter covers nested paths and, in on-demand routing,
can also match the top-level /api path with an undefined rest parameter; a separate
src/pages/api.ts re-export is not required. With output: 'static', prerender = false opts the
endpoint into on-demand rendering. With output: 'server', it is on-demand by default. Both modes
require a deployment adapter.
Start the app and verify the mounted HTTP boundary:
curl -i http://localhost:4321/api/users/adaHTTP/1.1 200 OK
content-type: application/json
{"id":"ada","name":"Ada Lovelace"}ALL exposes every method declared by the mounted contract, including the custom QUERY method.
For a contract GET operation, Astro HEAD dispatches through that GET operation and returns its
status and headers without a body. The adapter’s dispatch request is GET, while the native
astroContext.request remains HEAD. An unmatched path returns 404; a known path with an
unsupported method returns 405 with an Allow header.
| Caller | Use | What happens |
|---|---|---|
| Browser, mobile client, or another service | fetchTransport() | Sends HTTP to the mounted Astro endpoint. |
| Ordinary server-rendered component or server island in the same process | astroInProcessTransport(implementation, Astro) | Dispatches locally with the current Astro context and no internal HTTP request. |
| Application-local form or function with no durable shared HTTP need | Astro Action or native Astro behavior | Keeps the operation in Astro instead of adding a contract-backed endpoint. |
Keep implementation out of browser-reachable modules. A browser client imports only the shared
contract and reaches the mounted endpoint:
// src/api/browser-client.ts — browser-safe code
import { createClient } from '@hulla/api/client'
import { fetchTransport } from '@hulla/api/fetch'
import { contract } from './contract'
export const api = createClient(contract, {
transport: fetchTransport(),
})For example, an Astro page can call that client from a browser script:
<!-- src/pages/index.astro -->
<button id="load-user" type="button">
Load user
</button>
<output id="user-result"></output>
<script>
import { api } from '../api/browser-client'
document.querySelector('#load-user')?.addEventListener('click', async () => {
const result = await api.users.byId({ params: { id: 'ada' } })
const output = document.querySelector('#user-result')
if (output)
output.textContent =
result.status === 200 ? result.body.name : 'Unavailable'
})
</script>Use an absolute baseUrl when a server-side Fetch client has no request origin. Server-side Fetch
also does not inherit incoming cookies or headers unless the application forwards them explicitly.
When the implementation is colocated with the Astro server, use the current Astro render context:
---
// src/pages/users/[id].astro
import { astroInProcessTransport } from '@hulla/api-astro'
import { createClient } from '@hulla/api/client'
import { contract } from '../../api/contract'
import { implementation } from '../../api/server'
export const prerender = false
const api = createClient(contract, {
transport: astroInProcessTransport(implementation, Astro),
})
const result = await api.users.byId({ params: { id: Astro.params.id! } })
if (result.status === 404) Astro.response.status = 404
---
{result.status === 200 ? <h1>{result.body.name}</h1> : <p>User unavailable</p>}This call bypasses the mounted endpoint and Astro endpoint processing, but it still runs @hulla/api
route selection, input validation, middleware, handler execution, response encoding, and client
decoding. The explicit Astro argument keeps native context request-scoped.
Astro owns the server:defer protocol. It serializes the component’s props, renders the fallback,
requests the internal island endpoint, and replaces the fallback with the returned HTML. The island
can use the same in-process transport and contract route:
---
// src/components/UserCard.astro
import { astroInProcessTransport } from '@hulla/api-astro'
import { createClient } from '@hulla/api/client'
import { contract } from '../api/contract'
import { implementation } from '../api/server'
interface Props {
id: string
}
const { id } = Astro.props
const api = createClient(contract, {
transport: astroInProcessTransport(implementation, Astro),
})
const result = await api.users.byId({ params: { id } })
---
{result.status === 200 ? (
<article>{result.body.name}</article>
) : (
<p>Unavailable</p>
)}---
// src/pages/profile.astro
import UserCard from '../components/UserCard.astro'
---
<UserCard id="ada" server:defer>
<p slot="fallback">Loading user…</p>
</UserCard>Pass only serializable page-derived values as island props. Inside the deferred component,
Astro.request describes Astro’s internal /_server-islands/... request, not the page containing
the island. The client still selects /api/users/:id as the contract route. Derive identity and
authorization from trusted cookies or middleware-populated locals, not from an assumed containing-page
URL.
Most implementations can remain portable, as in the earlier example. Bind context to Astro only
when handlers need locals, cookies, route parameters, or another value from APIContext:
// src/api/server.ts — replace the portable implementation with this version
import { astroAdapter } from '@hulla/api-astro'
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'
export const adapter = astroAdapter()
const users = new Map([['ada', 'Ada Lovelace']])
export const implementation = defineServer(contract, {
context: adapter.context(({ astroContext, request, route }) => ({
actor: astroContext.locals.actor,
dispatchMethod: request.method,
nativeMethod: astroContext.request.method,
operation: route.key,
})),
}).implement({
users: {
byId: ({ params, context, response }) => {
const name =
context.actor === params.id ? users.get(params.id) : undefined
return name === undefined
? response(404, { message: 'User not found' })
: response(200, { id: params.id, name })
},
},
})Declare the local shape in src/env.d.ts and populate it in Astro middleware before mounting the
route:
// src/env.d.ts
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
actor?: string
}
}// src/middleware.ts
import { defineMiddleware } from 'astro:middleware'
const sessions = new Map([['demo-session', 'ada']])
export const onRequest = defineMiddleware(({ cookies, locals }, next) => {
const session = cookies.get('session')?.value
locals.actor = session === undefined ? undefined : sessions.get(session)
return next()
})The replacement implementation returns the user only when the trusted actor local matches the
requested ID. The in-memory session map keeps the example complete; replace it with your
application’s session verification before deployment.
For a mounted endpoint, the context request is the request dispatched through @hulla/api,
astroContext.request is Astro’s original request, and route is the matched contract operation.
This is why an endpoint HEAD exposes GET through request while the native request remains
HEAD. For an in-process call, both request fields describe the supplied Astro page or island
request; route identifies the contract operation selected by the client.
astroInProcessTransport() accepts a portable implementation or one bound with
astroAdapter().context(). It rejects an implementation whose context is bound to a different
adapter.
onError customizes failures from the mounted HTTP endpoint. It is not invoked by
astroInProcessTransport(); declared response statuses remain part of the client’s result union.
// src/pages/api/[...api].ts
import { astroAdapter } from '@hulla/api-astro'
import { implementation } from '../../api/server'
export const prerender = false
export const ALL = astroAdapter({
onError({ error, phase, request, route, astroContext, defaultResponse }) {
console.error({
error,
phase,
url: request.url,
operation: route?.key,
path: astroContext.url.pathname,
})
return defaultResponse
},
}).mount(implementation)onError may return a replacement Response or undefined to keep the protocol-safe default. A
complete implementation or independently executable fragment can be passed to mount() and to
astroInProcessTransport(); fragments preserve their original contract paths. Use the
server composition guide for composition decisions rather than assuming
that fragments provide code splitting or smaller server chunks.
Astro owns file routing, rendering, middleware, actions, deployment, and the server-island protocol.
@hulla/api owns contract matching, validation, middleware, handler execution, and declared
responses. Keep the boundary explicit when you need endpoint middleware, HTTP response headers, or a
separately deployed caller; use in-process transport when the implementation is safely colocated.
Next, read server and browser calls for transport selection, server context for reusable context and middleware, and errors for declared failure responses. For host setup, see Astro’s on-demand rendering, endpoints, and server islands guides.