Based is the end-to-end platform for complete digital products built in TypeScript. Your apps, back office, data pipelines, API, MCP run on one live engine built for hyperscale.
One stack takes the friction out of getting things done. People and AI agents edit, test and verify the whole product, and keep iterating at day-one speed long after launch.
A real platform is a site, multiple apps, an API, MCP, custom agents, a CMS, users, files, search and live analytics. Today that's a dozen services glued together. With Based it's one TypeScript codebase, one schema and one deploy.
AI agents are on the team now. Based gives people and agents one place to shape and ship the whole product, on the same live data.
On a typical stack an agent works blind across a dozen services. On Based it can change anything and prove it works.
One TypeScript codebase, stored in the database.
A real environment for every change.
The whole product, browser included.
Live data, logs and analytics.
Real-time updates, including database migrations.
An agent is only as good as its context and only as safe as its limits.
Defined, tested and deployed with your product. Shape each agent's context in one query: traverse, filter, full-text and vector search, and aggregate, in microseconds. No retrieval pipeline, no separate vector store.
Data, code, logs and analytics as they are right now, in one typed system.
Connect Claude or any MCP client to the data, logs and analytics.
Agents make far more calls than people. Based doesn't flinch.
See how the product is really used, then build what's next.
The Based coding agent works on code in the database, not a file system.
People and agents in one live session.
React apps, server-rendered sites and a typed API.
CMS and admin tools on the same live data.
A real-time subscription engine. Schema changes in one step, live.
Auth, roles, secrets and a gate on every call.
Full-text search across all your languages, typo-tolerant and ranked, plus vector search. No search service.
TypeScript functions, uploads, media and streams.
Live analytics and logs next to your data. No warehouse.
One CLI, built-in tests and browser, an environment per PR.
Custom agents, the Based coding agent and MCP everywhere.
A vendor for every layer and glue in between means friction at every step, and it compounds with every layer you add. Based removes it, so your time goes into the product, not the plumbing.
No tickets, no ops queue.
Its own machines, databases and seed data.
Tests and browser run against it.
Posted on the PR for the team.
Cleaned up automatically.
Everything runs in one engine, so there are fewer places to attack and every request goes through the same checks.
Every request is checked against your access rules before any of your code runs. Built-in rate limits stop abuse.
Every user, service, environment and agent has its own identity, and has to prove it before it can do anything.
Each environment has its own data, secrets and access, so a preview or test can't touch your live product.
Run it on our cloud, your cloud, on-prem or fully air-gapped. Your data stays where you decide.
Built from the ground up in Zig and C: our own database, storage, query engine and network stack. Engineered for the biggest live moments on the internet, and the machine-scale traffic AI brings.
Faster than ClickHouse on filters.
Filters*Ahead of in-memory PostgreSQL on the same filter set.
vs Postgres*Live writes a second on one machine. 6× ClickHouse, 120× Postgres.
Writes**Of one CPU core for 1M live updates a second. Scales out to millions of users.
NetworkServer cost per request, with nothing to invalidate.
Requests & live cacheEvery query is a subscription. Most databases only answer when asked.
Subscription engineBased powers the voting for the Eurovision Song Contest, the largest audience participation event in the world.
All benchmarks run on an M2 MacBook Pro. * 1M rows, identical data and match counts. ** Competitors measured at best case: Postgres UNLOGGED with fsync off, ClickHouse single-stream bulk ingest.
A schema, a query and a write. That's a live app.
// schema/player.ts
export const player = { name: 'string', score: 'uint32' }
// functions/leaderboard.ts
export const leaderboard: BasedQueryFunction = ({ based }) =>
based.db.query('player').sort('score', 'desc').range(0, 10)
// functions/addPlayer.ts
export const addPlayer: BasedFunction<Player<'create'>> = ({ based, payload }) =>
based.db.create('player', payload)
// apps/web/Leaderboard.tsx
export const Leaderboard = () => {
const { data } = useQuery('leaderboard')
const client = useClient()
return (
<>
<ol>{data?.map((p) => <li key={p.id}>{p.name} · {p.score}</li>)}</ol>
<button onClick={() => client.call('addPlayer', { name: 'Shadow', score: 25 })}>
Join
</button>
</>
)
}
// click Join and every open leaderboard updates, instantlyReturn a query and every client stays in sync, scaling to millions of updates. No websocket code, no cache, no polling.
export const player = {
name: 'string',
region: 'string',
score: 'uint32',
}import type { BasedQueryFunction } from '@based'
export const leaderboard: BasedQueryFunction = ({ based }) =>
based.db
.query('player')
.include('name', 'region', 'score')
.sort('score', 'desc')
.range(0, 100)import { useQuery } from '@based'
export const Leaderboard = () => {
const { data } = useQuery('leaderboard')
return (
<ol>
{data?.map((p) => (
<li key={p.id}>{p.name} · {p.score}</li>
))}
</ol>
)
}Walk the graph to any depth, filter on the data stored on each link, and aggregate whatever you reach. One query, live.
export default {
types: {
player: {
name: 'string',
score: 'uint32',
online: 'boolean',
// $since is an edge: stored on the friendship itself
friends: {
items: {
ref: 'player',
prop: 'friends',
$since: 'timestamp',
},
},
matches: { items: { ref: 'match', prop: 'players' } },
},
match: {
hero: 'string',
kills: 'uint16',
},
},
}import type { BasedQueryFunction } from '@based'
const season = Date.now() - 90 * 86_400_000
// friends, and friends of friends, made this season
export const squad: BasedQueryFunction = ({ based, session }) =>
based.db
.query('player', session.userId)
.traverse(
(t) => t('friends').filter('$since', '>', season),
{ maxDepth: 2 },
)
.filter('online', '=', true)
.sort('score', 'desc')
.range(0, 20)
.include('name', 'score', '$since')import type { BasedQueryFunction } from '@based'
// every match your friends played, summed per hero
export const friendStats: BasedQueryFunction = ({ based, session }) =>
based.db
.query('player', session.userId)
.traverse('friends', { maxDepth: 1 })
.collect('matches')
.count()
.sum('kills')
.avg('kills')
.groupBy('hero')
// { Ranger: { count: 412, sum: { kills: 3304 }, avg: { kills: 8.02 } }, … }Typo-tolerant, ranked full-text search across every language you support. No search service.
export default {
locales: { en: true, de: true, ja: true },
types: {
item: { name: { type: 'string', localized: true } },
},
}import type { BasedQueryFunction } from '@based'
export const searchItems: BasedQueryFunction<{ q: string }> =
({ based, payload: { q } }) =>
based.db
.query('item')
.include('name')
.search(q, 'name')
.range(0, 20)
// 'excalbur' still finds 'Excalibur'Aggregations the UI subscribes to, updating as matches finish. No warehouse, no ETL.
import type { BasedQueryFunction } from '@based'
export const matchesPerHour: BasedQueryFunction = ({ based }) =>
based.db
.query('match')
.count()
.groupBy('endedAt', { step: 'hour' })const { data } = useQuery('matchesPerHour')
// { 20: { count: 48210377 }, 21: { count: 51094822 } }
// and it keeps updating, liveMatchmaking on playstyle embeddings, in the same query as everything else. No separate vector database.
export const player = {
name: 'string',
playstyle: {
type: 'vector',
size: 256,
baseType: 'float32',
},
}import type { BasedQueryFunction } from '@based'
export const findOpponents: BasedQueryFunction<{ playstyle: Float32Array }> =
({ based, payload: { playstyle } }) =>
based.db
.query('player')
.include('name')
.search(playstyle, 'playstyle', { fn: 'cosineSimilarity', score: 0.8 })
.range(0, 10)Validation lives in the schema and types are generated from it. No zod, no hand-written types.
export const player = {
name: { type: 'string', maxChars: 32, required: true },
region: ['eu', 'na', 'asia'],
score: { type: 'uint32', min: 0 },
}import type { BasedFunction, Player } from '@based'
// Player<'create'> is generated from the schema
export const addPlayer: BasedFunction<Player<'create'>> =
({ based, payload }) => based.db.create('player', payload)const client = useClient()
await client.call('addPlayer', {
name: 'Shadow',
region: 'eu',
})
await client.call('addPlayer', {
name: 'x'.repeat(64),
region: 'mars',
})
// ✕ TypeScript flags 'mars', and the schema
// rejects both fields before anything is writtenRules that types can't express live in the schema too, per field or across fields. Every write is checked, and every failing field comes back at once.
export const match = {
// across fields: checked on every write
validation: (m) =>
!m.startedAt || !m.endedAt || m.endedAt >= m.startedAt ||
'A match ends after it starts',
props: {
startedAt: 'timestamp',
endedAt: 'timestamp',
kills: { type: 'uint16', max: 500 },
mvp: {
type: 'string',
validation: (v) => !v.includes(' ') || 'No spaces in a player name',
},
},
}try {
await client.call('reportMatch', {
startedAt: now,
endedAt: now - 60_000,
kills: 9001,
mvp: 'Dark Knight',
})
} catch (e) {
e.errors
// [
// { rule: 'custom', message: 'A match ends after it starts' },
// { path: ['kills'], rule: 'max' },
// { path: ['mvp'], rule: 'custom', message: 'No spaces…' },
// ]
}Payloads that aren't a database type get a validator, in the same vocabulary as the schema. It types the payload and reports every error at once. No zod.
import { validator, type BasedFunction } from '@based'
const Report = validator({
match: { type: 'uint32', required: true },
players: {
type: 'array',
required: true,
items: {
type: 'object',
props: {
player: 'uint32',
team: ['red', 'blue'],
kills: { type: 'uint16', max: 500 },
},
},
},
})
export const reportMatch: BasedFunction<typeof Report.infer> = ({ based, payload }) => {
const { match, players } = Report.parse(payload) // throws every error at once
for (const p of players) {
based.db.create('result', { match, ...p })
}
}Upload match replays in chunks, with progress written to the database as it happens.
export const replay = {
name: 'string',
progress: 'number',
}import type { BasedStreamFunction } from '@based'
export const uploadReplay: BasedStreamFunction = async ({ based, payload: { stream, fileName } }) => {
const replay = based.db.create('replay', { name: fileName })
stream.on('progress', (p) =>
based.db.update('replay', replay, { progress: p }),
)
const { src } = await based.s3({ contents: stream })
return { replay, src }
}const client = useClient()
const { src } = await client.stream(
'uploadReplay',
{ contents: file, fileName: file.name },
(progress) => setProgress(progress),
)
// progress is in the database too, so every
// screen watching the replay sees it liveReferences link rows both ways, edges store data on the link itself, and json gets a format so it's validated and typed too.
export const player = {
name: 'string',
// a reference, with edges: data that belongs to the link
clan: {
ref: 'clan',
prop: 'members',
$role: ['leader', 'officer', 'member'],
$joinedAt: 'timestamp',
},
// many references, queryable from both sides
friends: {
items: { ref: 'player', prop: 'friends', $since: 'timestamp' },
},
// json with a format: validated on write, typed on read
loadout: {
type: 'json',
format: {
hero: { type: 'string', required: true },
skin: 'string',
perks: { type: 'array', items: { name: 'string', level: 'uint8' } },
},
},
}Add props, widen numbers or extend enums and deploy. The live data migrates by itself, while the platform keeps serving.
export const player = {
name: 'string',
score: 'uint16',
region: ['eu', 'na'],
}export const player = {
name: 'string',
score: 'uint32', // widened
region: ['eu', 'na', 'asia'], // extended
banned: 'boolean', // added
}For anything else, write the step yourself. It runs on a copy and swaps in one step. If anything throws, nothing changes.
export default {
types: {
player: { name: 'string', tag: 'string' },
},
async migrate(newDb, oldDb) {
await oldDb.query('player').include('name').forEach((p) => {
// 'Shadow#4821' becomes name 'Shadow' and tag '4821'
const [name, tag] = p.name.split('#')
newDb.update('player', p.id, { name, tag })
})
await newDb.drain()
},
}A function exports its own rule, and it runs before every call. This one lets only signed-in players edit their own profile.
import type { Authorize, BasedFunction, Player } from '@based'
export const authorize: Authorize = ({ session }) => !!session.userId
export const editProfile: BasedFunction<Player<'update'>> = ({ based, payload, session }) =>
based.db.update('player', session.userId, payload)Real platforms need more than signed in or not. Because rules are just queries, complex ones stay short: one walks every group, sub-group and folder a user belongs to, at any depth, and the other reads the role stored on the membership itself.
export default {
types: {
user: {
groups: {
items: {
ref: 'group',
prop: 'members',
$role: ['owner', 'editor', 'viewer'],
},
},
},
group: {
groups: { items: { ref: 'group', prop: 'parents' } },
folders: { items: { ref: 'folder', prop: 'groups' } },
},
folder: {
folders: { items: { ref: 'folder', prop: 'parents' } },
archived: 'boolean',
files: { items: { ref: 'file', prop: 'folders' } },
},
file: { name: 'string', updatedAt: 'timestamp' },
},
}import type { Authorize, BasedFunction } from '@based'
// groups → sub-groups → folders → subfolders → files
export const authorize: Authorize = async ({ based, payload: { id }, session }) => {
const { count } = await based.db
.query('user', session.userId)
.traverse('groups', 'folders')
.collect('files')
.filter('id', '=', id)
.count()
.get()
return count > 0
}
export const openFile: BasedFunction<{ id: number }> = ({ based, payload: { id } }) =>
based.db.query('file', id).include('name', 'src').get()import type { Authorize, BasedFunction } from '@based'
// reads the $role edge on the membership
export const authorize: Authorize = async ({ based, payload: { id }, session }) => {
const group = await based.db
.query('group', id)
.include((s) =>
s('members').filter('id', '=', session.userId).include('$role'),
)
.get()
return group?.members[0]?.$role === 'owner'
}
export const deleteGroup: BasedFunction<{ id: number }> = ({ based, payload: { id } }) =>
based.db.delete('group', id)The same walk as a live query. Join a group and its files appear, everywhere, straight away.
import type { BasedQueryFunction } from '@based'
export const myFiles: BasedQueryFunction = ({ based, session }) =>
based.db
.query('user', session.userId)
.traverse(
'groups',
(t) => t('folders').filter('archived', '=', false),
)
.collect('files')
.sort('updatedAt', 'desc')
.range(0, 50)
.include('name', 'updatedAt')Call functions and wait on live queries the way the app does. Rejections are asserted by error code, not message.
import type { BasedTestFunction } from '@based'
export const playersTest: BasedTestFunction = async ({ based, t }) => {
await t.test('a new player shows up on the live leaderboard', async () => {
const id = await based.call('addPlayer', { name: 'Shadow', region: 'eu' })
await t.until(
based.query('leaderboard'),
(rows) => rows.some((p) => p.id === id),
'Shadow is on the board',
)
})
await t.test('rejects a region that does not exist', async () => {
await t.throws(
// @ts-expect-error: the type refuses it too
() => based.call('addPlayer', { name: 'Shadow', region: 'mars' }),
'schema/validation-error',
)
})
}The built-in browser drives the real app: clicks, live updates, layouts on desktop and mobile, and access rules through the real client.
import type { BasedTestFunction } from '@based'
export const leaderboardTest: BasedTestFunction = async ({ based, t }) => {
await t.test('a win shows up live', async () => {
await based.browser.open(app, async (page) => {
await page.click('Match button', 'Report win')
await page.waitFor('Leaderboard', 'Shadow · 25')
})
})
await t.test('the leaderboard fits on every screen', async () => {
const fits = await based.browser.open(app, { layout: ['desktop', 'mobile'] }, (page) =>
page.exec(({ select, window }) => select('Leaderboard')[0].scrollWidth <= window.innerWidth),
)
t.deepEqual(fits, [true, true])
})
await t.test('only owners can delete a group', async () => {
await based.browser.open(app, async (page) => {
await t.throws(
() => page.based.call('deleteGroup', { id: group }),
'fn/authorize-rejected',
)
})
})
}Our own database, storage and network stack, built from the ground up in Zig and C.
Apps, back office, API, MCP, auth, analytics and deploys. Cloud or self-hosted.
Live collab sessions and first-class custom agents, building products together.