---
title: Routes
description: Serve every session with `server.fetch` over HTTP or a WebSocket, or build your own route from session methods.
---

## The HTTP route

```ts app/api/public-order-events/route.ts
import { ordersServer } from '@/server/orders'

export const GET = ordersServer.fetch
export const POST = ordersServer.fetch
```

`server.fetch` is bound, so an unprotected route is two exports. A plain GET
streams one session over SSE, a GET with `gte` and `lte` returns a history
slice, and POST appends the pushed events. If the contract declares presence,
the same route streams and accepts it.

Wrap the call when the route needs authentication or authorization:

```ts app/api/protected-order-events/route.ts
import { ordersServer } from '@/server/orders'

async function fetchOrders(request: Request): Promise<Response> {
  // your authentication:
  const user = { id: 'user-1' }
  if (!user) return new Response(null, { status: 401 })

  return ordersServer.fetch(request, {
    authorize(operation) {
      // your per-session and per-operation authorization:
      // return canAccess(user, operation)
      return true
    },
  })
}

export const GET = fetchOrders
export const POST = fetchOrders
```

Authenticate in the route, where headers, cookies, and framework context
live. `authorize` then checks each typed operation (`stream`, `history`, or
`push` with its parsed events and presence patch) after A2 parses it and
before schema validation or durable I/O. Return `false` to answer
`FORBIDDEN` (403). A throw is an infrastructure failure and answers
`STORE_UNAVAILABLE`.

If a URL or connection token is scoped to one session, `authorize` must
compare that scope to `operation.sessionId`. Keep URL tokens short-lived:
URLs can appear in infrastructure logs.

## The WebSocket route

```ts app/api/socket-order-events/route.ts
import { experimental_upgradeWebSocket } from '@vercel/functions'
import { ordersServer } from '@/server/orders'

export const POST = ordersServer.fetch

export function GET(request: Request): Promise<Response> {
  return ordersServer.fetch(request, {
    upgradeWebSocket: (attach) =>
      experimental_upgradeWebSocket(attach, {
        maxPayload: 4 * 1024 * 1024,
      }),
  })
}
```

Pass the platform's upgrade capability on GET; POST can stay a direct export.
The socket is bound to the `sessionId` in its upgrade URL: A2 authorizes that
session before upgrading, and frames can't switch sessions. Each connected
session has its own socket.

Authenticate the upgrade request before calling `server.fetch`. For browser
sockets authenticated by cookies, validate the request's `Origin` too: a
WebSocket handshake has no CORS preflight. Then `authorize` checks the session
stream and every push or presence frame. There is no separate upgrade
operation.

To share one socket across sessions, opt in on the route and set
`multiplex: true` on the [client](/client/transports):

```ts app/api/multiplexed-order-events/route.ts
import { experimental_upgradeWebSocket } from '@vercel/functions'
import { ordersServer } from '@/server/orders'

export function GET(request: Request): Promise<Response> {
  return ordersServer.fetch(request, {
    multiplexWebSocket: true,
    upgradeWebSocket: (attach) =>
      experimental_upgradeWebSocket(attach, {
        maxPayload: 4 * 1024 * 1024,
      }),
  })
}
```

Enable multiplexing only when the request's authentication context may open
more than one session and `authorize` checks each session id. Subscribe frames
open sessions after the upgrade.

Denials stay scoped:

- A denied subscribe on a multiplexed socket gets an `unsubscribed` notice
  with `FORBIDDEN` and leaves other sessions connected. A failed stream or a
  thrown authorization check carries `STORE_UNAVAILABLE`. The client hands
  these to its reconnection policy as `A2ClientError`s.
- A denied event push gets a non-retryable `FORBIDDEN` ack.
- Denied presence is dropped silently; presence repaints on the next update.

A2 reads the platform's invocation deadline and closes the socket cleanly
before it.

## Build your own

`server.fetch` speaks the wire A2's client expects. For anything else, such as
another framework, your own framing, or a consumer that isn't A2's client,
build the route from session methods. It's just an event log: read a snapshot,
follow the stream, append.

```ts app/api/orders/[orderId]/live/route.ts
import { orders } from '@/contracts'
import { ordersServer } from '@/server/orders'

type Params = { params: Promise<{ orderId: string }> }

const encoder = new TextEncoder()
const frame = (name: string, data: unknown): Uint8Array =>
  encoder.encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)

export async function GET(
  _request: Request,
  { params }: Params,
): Promise<Response> {
  const { orderId } = await params
  // here's where you'd do auth, or any other checks
  const session = ordersServer.session(orderId)
  const snapshot = await session.state(orders.reducer)
  const events = session
    .stream({ startAfter: snapshot.index })
    [Symbol.asyncIterator]()

  const body = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(frame('snapshot', snapshot))
    },
    async pull(controller) {
      const next = await events.next()
      if (next.done) return controller.close()
      const event = next.value
      const name = event.type === 'a2.cleared' ? 'cleared' : 'event'
      controller.enqueue(frame(name, event))
    },
    // the browser left: end the subscription
    async cancel() {
      await events.return?.()
    },
  })
  return new Response(body, {
    headers: { 'content-type': 'text/event-stream' },
  })
}
```

`session.state()` returns the snapshot's `index`, so the stream picks up
exactly where the snapshot ends. A clear arrives as an `a2.cleared` marker in
the same stream. Writes go through `session.append`, which validates against
the contract:

```ts app/api/orders/[orderId]/live/route.ts
import { A2Error } from 'experimental-a2'

export async function POST(
  request: Request,
  { params }: Params,
): Promise<Response> {
  const { orderId } = await params
  // here's where you'd do auth, or any other checks
  const { shopId, items } = await request.json()
  try {
    const [event] = await ordersServer.session(orderId).append({
      type: 'created',
      payload: { shopId, items },
    })
    return Response.json(event)
  } catch (error) {
    if (error instanceof A2Error && error.code === 'INVALID_PAYLOAD') {
      return Response.json({ issues: error.details }, { status: 400 })
    }
    throw error
  }
}
```

The browser follows it with `EventSource` and folds with the contract's own
reducer, the same fold the server runs:

```ts app/orders/[orderId]/follow-order.ts
'use client'
import { orders } from '@/contracts'

type OrderState = typeof orders.reducer.initialState

export function followOrder(
  orderId: string,
  onChange: (state: OrderState) => void,
): () => void {
  const url = `/api/orders/${encodeURIComponent(orderId)}/live`
  const source = new EventSource(url)
  let state = orders.reducer.initialState
  const update = (next: OrderState): void => {
    state = next
    onChange(state)
  }
  source.addEventListener('snapshot', (message) => {
    update(JSON.parse(message.data).state)
  })
  source.addEventListener('event', (message) => {
    update(orders.reducer.fold(state, JSON.parse(message.data)))
  })
  source.addEventListener('cleared', () => {
    update(orders.reducer.initialState)
  })
  return () => source.close()
}
```

`EventSource` reconnects on its own, and each connection starts from a fresh
snapshot. What `server.fetch` adds on top, and a custom route takes on itself:

- resuming a stream from the client's last `index` instead of a new snapshot
- bounded history reads (`gte` and `lte`)
- the push envelope with acks and `.confirmed`, and presence
- WebSocket upgrades
- `authorize` on every operation; direct `session.append` bypasses it, so the
  route is where your checks go
- closing streams before the platform's invocation deadline

A2's client, `createClient` and `useSession`, talks to `server.fetch`. Pair a
custom route with your own client.
