USERDO: PER-USER DURABLE OBJECTS
User data pods with live updates and boring ops.
TECHNICAL GUIDE 2025.09.15
Each user gets a Durable Object—ordered state, simple auth, instant SSE.
THE QUICK VERSION
Give every user their own Durable Object. Read/write their profile and stream updates over SSE. It’s simple, ordered, and fast.
Great for settings, presence, inboxes, and profile-driven UIs.
ARCHITECTURE
Client ↔ Worker → UserDO(name = user.sub)
↓ ↓
JWT auth SSE updatesINFRASTRUCTURE
Install the package.
# Install bun install userdo
YOUR DURABLE OBJECT
Extend `UserDO` to add your tables and methods.
// 1) Extend UserDO (your data + logic)
import { UserDO, type Env } from "userdo/server";
import { z } from "zod";
const PostSchema = z.object({ title: z.string(), content: z.string() });
export class BlogDO extends UserDO {
posts: any;
constructor(state: DurableObjectState, env: Env) {
super(state, env);
this.posts = this.table('posts', PostSchema, { userScoped: true });
}
async createPost(title: string, content: string) {
return await this.posts.create({ title, content });
}
async getPosts() {
return await this.posts.orderBy('createdAt', 'desc').get();
}
}WORKER
Auth endpoints come built-in. Add your own routes.
// 2) Create Worker (auth built-in; add your endpoints)
import { createUserDOWorker, createWebSocketHandler, getUserDOFromContext } from 'userdo/server';
import type { BlogDO } from './blog-do';
const app = createUserDOWorker('BLOG_DO');
const wsHandler = createWebSocketHandler('BLOG_DO');
app.post('/api/posts', async (c) => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
const { title, content } = await c.req.json();
const blog = getUserDOFromContext(c, user.email, 'BLOG_DO') as unknown as BlogDO;
const post = await blog.createPost(title, content);
return c.json({ post });
});
export default {
async fetch(request: Request, env: any, ctx: any) {
if (request.headers.get('upgrade') === 'websocket') return wsHandler.fetch(request, env, ctx);
return app.fetch(request, env, ctx);
}
};CLIENT
Use the browser client. It handles auth and real-time.
// 4) Browser client
import { UserDOClient } from 'userdo/client';
const client = new UserDOClient('/api');
await client.signup('user@example.com', 'password');
await client.login('user@example.com', 'password');
client.onChange('table:posts', (evt) => console.log('post changed', evt));WRANGLER CONFIG
Enable SQLite for your DO via migration.
{
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
"vars": { "JWT_SECRET": "your-jwt-secret-here" },
"durable_objects": {
"bindings": [ { "name": "BLOG_DO", "class_name": "BlogDO" } ]
},
"migrations": [ { "tag": "v1", "new_sqlite_classes": ["BlogDO"] } ]
}BUILT-IN ENDPOINTS
Built-in endpoints: - POST /api/signup - POST /api/login - POST /api/logout - GET /api/me - GET /api/ws (WebSocket) - GET /data - POST /data - Organizations: /api/organizations...
ORGANIZATIONS
Org-scoped tables and membership management.
// Organization-scoped example
import { UserDO, type Env } from 'userdo/server';
import { z } from 'zod';
const Project = z.object({ name: z.string() });
const Task = z.object({ title: z.string() });
export class TeamDO extends UserDO {
projects: any; tasks: any;
constructor(state: DurableObjectState, env: Env) {
super(state, env);
this.projects = this.table('projects', Project, { organizationScoped: true });
this.tasks = this.table('tasks', Task, { organizationScoped: true });
}
}