The fullstack Node.js framework with structure, speed, and AI built in.
Rudder is a batteries-included, modular TypeScript framework for Node.js. Ship a signup flow, a background queue, a real-time collaborative document, and an AI agent from one monorepo — wired through a DI container, an active-record ORM, and a single rudder CLI.
// routes/web.ts
import { Route } from '@rudderjs/router'
import { view } from '@rudderjs/view'
import { User } from 'App/Models/User.js'
Route.get('/dashboard', async () => {
const users = await User.all()
return view('dashboard', { users })
})// app/Views/Dashboard.tsx
export interface Props { users: User[] }
export default function Dashboard({ users }: Props) {
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}That's a typed, SSR'd /dashboard rendered through Vike — full SPA navigation, no Inertia adapter, no JSON envelope. Export Props and the view('dashboard', ...) call is type-checked at the controller. The same router chain serves JSON APIs, queued jobs, scheduled tasks, WebSocket channels, and AI agents.
- SSR views from a controller —
return view('id', props)renders typed React / Vue / Solid through Vike: SPA nav, ~400 bytes/nav, no Inertia tax.terminal('id', props)does the same in the terminal via Ink. - AI-native — 15 providers (Anthropic, OpenAI, Google, Bedrock, Mistral, and more), agents with tools, streaming, MCP, queue-backed runs, and approval gates.
- Real-time on one port — WebSocket channels, presence, and Yjs CRDT collab on the same Hono server. No second daemon, no proxy.
- Service-oriented — DI container with ALS request scope, service providers, gates & policies, and an active-record ORM (native, Prisma, or Drizzle).
- Pay-as-you-go — 51
@rudderjs/*packages; start with three. Swap adapters (native ↔ Prisma ↔ Drizzle, BullMQ ↔ Inngest, local ↔ S3) without touching app code. - Auto-discovery — install a package, done. The provider manifest self-heals at boot — no imports, no provider array to maintain. Laravel-style discovery for Node.
- One CLI —
rudder make:*,queue:*,db:*, and your own commands, plusdoctor --fixand introspection (route:list,event:list,config:show). - Typed end to end — views, routes, models,
config(), andEnv.get()each typed from one convention. Validators are Standard Schema (Zod / Valibot / ArkType); strict ESM + NodeNext, WinterCG-compatible. - Code-first API docs —
.responds(schema)+@rudderjs/openapiemit an OpenAPI 3.1 spec + Swagger UI from your route table. No YAML, opt-in, FastAPI-style.
Thirteen features, thirteen snippets. Each one is real code from the playground — copy, run, ship.
// bootstrap/app.ts
import 'reflect-metadata'
import { Application } from '@rudderjs/core'
import { RateLimit, CsrfMiddleware } from '@rudderjs/middleware'
import config from '../config/index.ts'
import providers from './providers.ts'
export default Application.configure({ config, providers })
.withRouting({
web: () => import('../routes/web.ts'),
api: () => import('../routes/api.ts'),
commands: () => import('../routes/console.ts'),
})
.withMiddleware((m) => {
m.web(CsrfMiddleware())
m.web(RateLimit.perMinute(120))
m.api(RateLimit.perMinute(60))
})
.create()One file — config, providers, routing, middleware groups, exception handlers (.withExceptions(...)), all in a fluent chain. The HTTP server adapter resolves itself (@rudderjs/server-hono, configured from config/server.ts) — pass server: hono(...) only to override it. No nested config trees, no decorators-at-the-root, no surprise files. And the config layer is typed end-to-end: config('app.name') autocompletes dot-paths from your own config/ directory, Env.get('DATABASE_URL') autocompletes the keys declared in .env.example — no codegen to remember for the former, one auto-regenerated registry for the latter.
// routes/web.ts
import { Route } from '@rudderjs/router'
import { view } from '@rudderjs/view'
Route.get('/dashboard', async () => view('dashboard'))// routes/api.ts — typed path, query, AND body in one declaration
import { Route, route } from '@rudderjs/router'
import { z } from '@rudderjs/core'
Route.post(
'/api/users/:id',
{
query: z.object({ notify: z.coerce.boolean().default(false) }),
body: z.object({ name: z.string(), email: z.string().email() }),
},
(req, res) => {
const id: string = req.params.id // from the path
const notify: boolean = req.query.notify // coerced from ?notify=1
const name: string = req.body.name // validated body
return res.json({ id, notify, updated: name })
},
)
.name('users.update')
.responds(z.object({ id: z.string(), updated: z.string() })) // typed response → OpenAPI
// `route()` URL generator — type-check params against the path (opt-in)
route('users.update', { id: 1, notify: true })
// → '/api/users/1?notify=true'Same router, same middleware engine — the web group runs through session + auth + CSRF, the api group is stateless by default. Path params, query, body, and response all infer from a single declaration; failure surfaces as 422 { errors: {...} } automatically. Schemas type against Standard Schema (Zod / Valibot / ArkType — Zod by default), and @rudderjs/openapi turns the same declarations into an OpenAPI 3.1 spec + Swagger UI, no hand-written YAML. Declare your named routes in RouteRegistry (see Typed Routes) and route() calls type-check too.
// app/Http/Controllers/UserController.ts
import { Controller, Get, Middleware } from '@rudderjs/router'
import { RateLimit } from '@rudderjs/middleware'
import { view } from '@rudderjs/view'
import { User } from 'App/Models/User.js'
@Controller('/users')
export class UserController {
@Get('/')
@Middleware([RateLimit.perMinute(60)])
async index() {
const users = await User.all()
return view('users.index', { users })
}
}// app/Views/Users/Index.tsx — SSR'd through Vike, props checked at the controller
export interface Props { users: User[] }
export default function Index({ users }: Props) {
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}Decorator controllers, fluent middleware, controller-returned SSR views. Export Props from the view and the matching view('id', ...) call is type-checked — wrong shape fails tsc, not render. No Inertia adapter, no JSON envelope.
// routes/console.ts — wire rudder commands
import { Rudder } from '@rudderjs/console'
import { terminal } from '@rudderjs/terminal'
import { User } from 'App/Models/User.js'
// Inline command — read DB, print stdout
Rudder.command('users:count', async () => {
console.log(`${await User.count()} users`)
}).description('Count users')
// Same handler, but renders an Ink component in the terminal
Rudder.command('dashboard', async () => {
return terminal('dashboard', { users: 1240, orders: 87 })
}).description('Show a terminal dashboard')// app/Terminal/Dashboard.tsx — typed props, React 19 + Ink
import { Box, Text } from 'ink'
export default function Dashboard({ users, orders }: { users: number; orders: number }) {
return (
<Box flexDirection="column" padding={1}>
<Text bold color="cyan">Daily snapshot</Text>
<Text>{users} users · {orders} orders</Text>
</Box>
)
}Run with pnpm rudder users:count or pnpm rudder dashboard. Scaffold new ones with make:command (plain handlers) or make:terminal (Ink components). Class-based commands extend Command for DI + signature parsing.
Need to poke at the DB or a service interactively? pnpm rudder tinker boots the app and drops into a Node REPL with app(), Route, every model in app/Models/, and the facades pre-imported — Laravel php artisan tinker parity:
$ pnpm rudder tinker
Rudder Tinker — node v22.14.0, env=local
> await User.count()
12
> const alice = await User.where('email', 'alice@example.com').first()
> alice.posts().count()
5
> route('users.show', { id: alice.id })
'/users/42'Top-level await, persistent history in ~/.rudder-tinker-history, .boot meta-command to pick up code changes.
// app/Models/Post.ts — column types GENERATED from your migrations (native engine)
import { Model } from '@rudderjs/orm'
export class Post extends Model.for<'posts'>() {
static table = 'posts'
static fillable = ['title', 'body', 'authorId']
// no id!/title!/body! — they come from the migrated schema, so they can't drift
}
// Anywhere — query, mutate, paginate
const recent = await Post.where('published', true).latest().paginate(1, 20)
const post = await Post.create({ title: 'Hello', body: 'World', authorId: 1 })
await post.update({ title: 'Hello, Rudder' })
// Full SQL when you need it — joins, CTEs, EXISTS subqueries, JSON paths, row locks
const polyglots = await Post.where('meta->lang', 'en').orWhereJsonContains('meta->tags', 'i18n').get()
const authors = await User.whereExists(
Post.query().whereColumn('posts.authorId', '=', 'users.id'),
).get()One Model API over three engines: the built-in native engine (@rudderjs/database — zero codegen, Laravel-style migrations + rollback, types generated from the schema as a migrate side effect) or Prisma / Drizzle. Swap engines in config without touching model code; relations (incl. polymorphic + through), soft deletes, observers, factories, casts, and read/write splitting with sticky reads work on all three.
Fast where it counts. In a parity-gated query-layer benchmark vs Prisma and Drizzle (SQLite / Postgres / MySQL, up to 100k rows), the native engine leads on bulk writes, hydration, and eager + pivot loading — competitors take the latency-bound single-statement ops, reported openly. → benchmarks
import { agent, toolDefinition } from '@rudderjs/ai'
import { z } from '@rudderjs/core'
const getWeather = toolDefinition({
name: 'get_weather',
description: 'Get the current weather for a city',
inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => `${city}: 22°C and sunny`)
const weatherAgent = agent({
instructions: 'You help people check the weather. Use get_weather when asked.',
model: 'anthropic/claude-haiku-4-5-20251001',
tools: [getWeather],
})
const reply = await weatherAgent.prompt('What is the weather in Tokyo?')
// reply.text, reply.steps, reply.usageSame agent works with Anthropic, OpenAI, Google, Groq, Ollama, xAI, DeepSeek, Mistral, Azure, Cohere, Jina. Add .stream() for SSE, run agents on the queue, gate tool calls with approval.
// routes/channels.ts — declare a presence channel
import { Broadcast } from '@rudderjs/broadcast'
Broadcast.channel('presence-lobby', async (req) => {
return { id: req.user?.id, name: req.user?.name }
})// anywhere — push to every subscriber
import { broadcast } from '@rudderjs/broadcast'
broadcast('chat', 'message', { user: 'Ada', text: 'Hi there', ts: Date.now() })WebSocket server bundled with @rudderjs/broadcast — no second daemon, no Pusher dependency. Auth, presence, and wildcard channels work out of the box.
// bootstrap/providers.ts
import { SyncProvider } from '@rudderjs/sync'
export default [
...(await defaultProviders()),
SyncProvider, // mounts /ws-sync on the same Hono server
]// client — any browser, any framework
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const doc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:3000/ws-sync', 'article:42', doc)
const text = doc.getText('content')
text.observe(() => console.log(text.toString()))
text.insert(0, 'Hello, collaborator!')Conflict-free merging, offline support, presence — same port as your HTTP server. Persist to memory, Redis, or Prisma.
// config/auth.ts
import { User } from 'App/Models/User.js'
export default {
defaults: { guard: 'web' },
guards: { web: { driver: 'session', provider: 'users' } },
providers: { users: { driver: 'eloquent', model: User } },
}// routes/web.ts
import { Route } from '@rudderjs/router'
import { auth, Gate } from '@rudderjs/auth'
import { registerAuthRoutes } from '@rudderjs/auth/routes'
registerAuthRoutes(Route) // /login, /register, /forgot-password, /reset-password
Gate.define('edit-post', (user, post: { authorId: number }) => user?.id === post.authorId)
Route.get('/me', async (req, res) => {
const user = await auth().user()
return res.json({ user })
})AuthMiddleware auto-installs on the web group — req.user is populated for every web route. Login/register pages are vendored into app/Views/Auth/ at scaffold time so the app owns the files. Gate + Policy mirror Laravel's authorization API.
// app/Http/Requests/CreateUserRequest.ts
import { FormRequest, z } from '@rudderjs/core'
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters.'),
email: z.string().email('Invalid email address.'),
role: z.enum(['admin', 'user']).optional().default('user'),
})
export class CreateUserRequest extends FormRequest<typeof schema> {
rules() { return schema }
}// routes/api.ts
Route.post('/api/users', async (req, res) => {
const data = await new CreateUserRequest().validate(req)
// data is typed: { name: string; email: string; role: 'admin' | 'user' }
return res.json({ created: data })
})Validation failures throw a ValidationError that the framework auto-renders as 422 { errors: {...} } for JSON requests, or flashes back to the form with old data on web routes. Lifecycle hooks (prepareForValidation, messages, after, passedValidation, failedValidation) match Laravel's FormRequest.
// app/Mcp/EchoServer.ts
import { McpServer, Name, Version, Instructions } from '@rudderjs/mcp'
import { EchoTool } from './EchoTool.js'
@Name('echo-server')
@Version('1.0.0')
@Instructions('A demo MCP server that echoes messages back.')
export class EchoServer extends McpServer {
protected tools = [EchoTool]
}// app/Mcp/EchoTool.ts — typed input, DI-injected dependencies
import { z } from '@rudderjs/core'
import { McpTool, McpResponse, Description, Handle } from '@rudderjs/mcp'
import { GreetingService } from 'App/Services/GreetingService.js'
@Description("Greets someone by name using the app's GreetingService")
export class EchoTool extends McpTool {
schema() { return z.object({ name: z.string().describe('The name to greet') }) }
@Handle(GreetingService)
async handle(input: Record<string, unknown>, greeter: GreetingService) {
return McpResponse.text(greeter.greet(String(input['name'])))
}
}Mount over HTTP or stdio. Inspect tool calls live with pnpm rudder mcp:inspector. Bridge an Agent straight to MCP with mcpServerFromAgent(MyAgent) — Laravel doesn't ship this; Rudder does.
// app/Jobs/WelcomeUserJob.ts
import { Job } from '@rudderjs/queue'
export class WelcomeUserJob extends Job {
static override queue = 'default'
static override retries = 3
constructor(private readonly name: string, private readonly email: string) {
super()
}
async handle() {
// send mail, sync CRM, whatever
}
failed(error: unknown) {
console.error('[WelcomeUserJob] failed:', error)
}
}// anywhere — dispatch from a controller, event listener, or another job
await WelcomeUserJob.dispatch('Ada', 'ada@example.com').send()
await WelcomeUserJob.dispatch('VIP', 'vip@example.com').onQueue('priority').send()Zero-infra native driver (persists to the built-in ORM engine — auto-creates its jobs/failed_jobs tables, no Redis) by default; sync for dev, BullMQ (Redis) + Inngest adapters for prod. Run workers with pnpm rudder queue:work. Monitor live with @rudderjs/horizon (Laravel Horizon equivalent).
// routes/console.ts
import { Schedule } from '@rudderjs/schedule'
import { Cache } from '@rudderjs/cache'
Schedule.call(async () => {
await Cache.forget('users:all')
}).everyFiveMinutes().description('Flush users:all cache')
Schedule.call(() => sendDigest())
.weekdays()
.dailyAt('9:00')
.timezone('America/New_York')
.description('Morning digest')Run the scheduler with pnpm rudder schedule:work (long-lived) or schedule:run (one-shot, cron-driven). Frequency helpers, timezones, overlap prevention, and per-task descriptions surface in pnpm rudder schedule:list.
Pick any package manager — the installer auto-detects it:
pnpm create rudder my-app
# or: npm create rudder@latest my-app
# or: yarn create rudder my-app
# or: bunx create-rudder my-appThe installer asks one question — "What are you building?" — and picks a recipe (Web app · SaaS · API service · Realtime · Minimal · Custom), a database, a frontend framework, and styling. Then it installs deps, sets up the database (native migrations by default; Prisma/Drizzle generate + push if you picked those), publishes auth views, and initializes git — all without leaving the prompt.
cd my-app && pnpm devVisit http://localhost:3000. Done.
Adding packages later.
pnpm rudder add queueinstalls the package, generates its config, registers it inconfig/index.ts, and refreshes the provider manifest.pnpm rudder remove queuereverses it. See the CLI guide.Something not working?
pnpm rudder doctorchecks env, structure, deps, ORM, and runtime — one line per failure plus a paste-able fix. Add--fixto auto-apply the safe ones. See the Doctor guide.
Three foundation packages get you running. The rest are opt-in.
Foundation — core · router · server-hono · middleware · console · cli · terminal · support · contracts · json-schema
HTTP & frontend — view · vite · session · openapi
Data — orm · database · orm-prisma · orm-drizzle · cache · storage
Auth & security — auth · hash · crypt · sanctum · passport · socialite
Billing — cashier-paddle
Workloads — queue · queue-bullmq · queue-inngest · schedule · concurrency · process
Communication — mail · notification · broadcast · broadcast-redis · sync
AI & tooling — ai · mcp · boost
Developer experience — log · http · context · pennant · localization · image · testing
Observability — telescope · pulse · horizon
| Layer | Default | Swap with |
|---|---|---|
| HTTP | Hono | pluggable server adapter |
| ORM / database | Native engine (built-in, @rudderjs/database) |
Prisma, Drizzle |
| Auth | Native session | Sanctum (API tokens), Socialite (OAuth login), Passport (OAuth2 server) |
| Queue | Native (database-backed, @rudderjs/queue/native) |
BullMQ (Redis), Inngest |
| Cache | In-memory | Redis |
| Storage | Local disk | S3, R2, MinIO |
| Log (dev) | SMTP via Nodemailer |
Get started
Core
- Routing · Middleware · Controllers · Requests · Responses · Validation · Frontend / views
- Service providers · DI container · Facades · Events · Error handling
Data
Auth & security
Workloads & messaging
- Queues · Scheduling · Mail · Notifications · Broadcasting · Sync (CRDT)
AI & MCP
More
- HTTP client · Logging · Localization · Rudder CLI · Rudder Doctor · Testing · Deployment
Modern Node.js forces a choice: great DX in a framework-locked box (Next.js), freedom with weeks of wiring (Express / Hono), structure without fullstack views (NestJS / Adonis).
Rudder is the middle ground — batteries-included, modular, UI-agnostic, fullstack-first.
| Next.js | NestJS | AdonisJS | Rudder | |
|---|---|---|---|---|
| Philosophy | Component-first | Angular-style DI | Full MVC port | Service-oriented, modular |
| Build tool | Webpack / Turbopack | Webpack / esbuild | Webpack (stencil) | Vite |
| UI framework | React only | API only | Edge templates / Inertia | React, Vue, Solid, or none |
| SSR views from controllers | N/A | ✗ | Inertia adapter | ✓ native — no Inertia, no JSON envelope |
| DI container | None | Class-based IoC | IoC | Service Providers + ALS request scope |
| AI-native | ✗ | ✗ | ✗ | ✓ 15 providers, agents, streaming, MCP |
| Real-time collab | ✗ | ✗ | ✗ | ✓ Yjs CRDT + WebSocket on same port |
| Modularity | All-in | All-in | Preset-based | Pay-as-you-go — 48 opt-in packages |
Rudder is fully on 1.0+ as of 2026-05-02. Every published @rudderjs/* package has a stable public API; breaking changes from here on require explicit major bumps and migration notes.
Rudder uses independent versioning — each @rudderjs/* package has its own version line, matching the norm across the npm ecosystem. A higher major reflects iteration history, not "more important."
git clone https://github.com/rudderjs/rudder.git
cd rudder
pnpm install
pnpm build
pnpm testSee CONTRIBUTING.md for dependency conventions and the package merge policy.
MIT © Rudder
