A2 syncs your immutable event log.
pnpm add experimental-a2npx skills add vercel-labs/a2Define a shared contract
Name the events once. The server implements the contract, and the browser types itself off it.
import { z } from 'zod'
import * as a2 from 'experimental-a2'
export const orders = a2.contract({
name: 'orders',
events: {
'order.created': z.object({
shopId: z.string(),
items: z.array(z.string()),
}),
'shop.notified': z.object({ shopId: z.string() }),
'courier.assigned': z.object({ courierId: z.string() }),
'order.delivered': z.object({ signedBy: z.string() }),
'order.cancelled': z.object({ reason: z.string() }),
},
reducer: {
name: 'order-status',
initialState: { status: 'new' },
fold: (state, event) => ({ status: event.type }),
},
})Implement the server
Bind the contract to a store, a scheduler, and the handlers that react to its events.
import { createServer } from 'experimental-a2/server'
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
import { redis } from 'experimental-a2/store-redis'
import { orders } from '@/contracts'
export const ordersServer = createServer({
contract: orders,
store: redis({ url: process.env.REDIS_URL }),
scheduler: vercelQueues(),
handlers: {
'order.created': async ({ event }) => {
// your side effect; event.id makes a stable idempotency key:
// await notifyShop(event.payload, { idempotencyKey: event.id })
const { shopId } = event.payload
return { type: 'shop.notified', payload: { shopId } }
},
},
})One route serves every session to the browser:
import { ordersServer } from '@/server/orders'
export const GET = ordersServer.fetch
export const POST = ordersServer.fetchImplement the client
The browser follows a session over SSE or a
WebSocket and folds the same state. push applies
instantly and rolls back if the server says no.
'use client'
import { createClient } from 'experimental-a2/client'
import { useSession } from 'experimental-a2/react'
import { orders } from '@/contracts'
const ordersClient = createClient({
contract: orders,
// SSE; for a WebSocket: { type: 'ws', url: '/api/orders' }
api: '/api/orders',
})
export function Order({ orderId }: { orderId: string }) {
const { state, push } = useSession(ordersClient, orderId)
return (
<button
onClick={() =>
void push({ type: 'order.cancelled', payload: { reason: 'customer' } })
}
>
{state.status} · cancel
</button>
)
}It's just an event log
// anywhere on the server: a route, a webhook, a cron job
import { ordersServer } from '@/server/orders'
const order = ordersServer.session('order-8194')
await order.append({
type: 'courier.assigned',
payload: { courierId: 'courier-7' },
})
const events = await order.history()
// order.created, shop.notified, courier.assignedA session is the ordered list of what happened. State is a fold over it, on both sides of the wire. Everything else A2 keeps is a cache it can rebuild from the log.
With strong guarantees
- Appends are effectively-once.
- The log stays ordered.
- Handlers are at-least-once.
- Returned events commit with the handler's completion.
- Handlers run concurrently, unless they share a lane.
- Failures stay contained.
- A failing handler never makes
appendthrow.
Guarantees has the details and the limits.
Go further...
Retries, lanes, and idempotency.
Reducers and the current view.
The raw log, oldest first.
Agents with tools and approvals.
Schedule an event for later.
A store and a scheduler.
FAQ
Can I use A2 without handlers?
Yes. Omit handlers and use append, history, reducers, and live streams as
a durable event log. Events settle in their append transaction, with no drain,
claim, or queue message. A contract can also mix handled and history-only event
types. A2 decides per event type.
How do I schedule an event for later?
Configure Vercel Queues or QStash, then call
session.schedule(name, { delay: '30s' }, event) or use { at: Date }. The
same scheduler route handles recovery and the delayed append. Names and omitted
event ids stay stable across retries. See Timers.
How do I cancel work that's already running?
Cancellation is an event too: same route, same auth, same log. Handlers
opt in with abortOn and receive a live AbortSignal; reducers cut the
derived view exactly. See Cancellation.
What happens when a handler keeps failing?
It retries with backoff, then dead-letters after ten failed attempts, whether the handler threw or its process ended. Later events in the same lane wait until you retry or skip it; unlaned events and other lanes continue. Guarantees has the exact guarantees and the recovery story behind them.
What does production need?
A durable store backend, such as Postgres or Redis. Add a queue-backed scheduler
when the contract has handlers or calls session.schedule. A history-only
contract with no timers needs no reaction infrastructure. Development needs
neither: SQLite appears under .a2/ and state survives restarts. See
Stores and Schedulers.
What does A2 sync?
One session whose id the application already knows. The browser follows its events, folds them into live state, and can push changes optimistically. A2 does not replicate database tables or subscribe to queries across sessions.
Where does my application database fit?
A2 does not replace it. Keep collections, relationships, constraints, search, and reporting in your application database. Split ownership instead of writing the same fact through both paths. See Sessions and your database.
Does the package include complete examples?
Yes. Every installation includes a standalone Next.js playground at
node_modules/experimental-a2/examples/playground. It contains complete
contracts, reducers, servers, route handlers, clients, and schedulers using only
public package entry points. See Complete examples.