fluent-convex
v0.12.3
Published
A fluent API builder for Convex functions with middleware support, inspired by oRPC
Maintainers
Readme

Fluent Convex
A fluent API builder for Convex functions with middleware support, inspired by oRPC.
Live Docs & Interactive Showcase -- see every feature in action with live demos and real source code.
Features
- Middleware support - Compose reusable middleware for authentication, logging, and more (docs)
- Callable builders - Define logic once, call it directly from other handlers, and register it multiple ways (docs)
- Type-safe - Full TypeScript support with type inference
- Fluent API - Chain methods for a clean, readable syntax (docs)
- Plugin system - Extend with plugins like
fluent-convex/zodfor Zod schema support (docs) - Extensible - Build your own plugins with the
_clone()factory pattern (docs) - Works with Convex - Built on top of Convex's function system
Installation
npm install fluent-convexQuick Start
For a complete walkthrough with live demos, see the Getting Started guide.
Important: All functions must end with .public() or .internal() to be registered with Convex.
import { createBuilder } from "fluent-convex";
import { v } from "convex/values";
import type { DataModel } from "./_generated/dataModel";
const convex = createBuilder<DataModel>();
// Simple query
export const listNumbers = convex
.query()
.input({ count: v.number() })
.handler(async (context, input) => {
const numbers = await context.db
.query("numbers")
.order("desc")
.take(input.count);
return { numbers: numbers.map((n) => n.value) };
})
.public(); // Must end with .public() or .internal()
// With middleware
const authMiddleware = convex.query().createMiddleware(async (context, next) => {
const identity = await context.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
return next({
...context,
user: {
id: identity.subject,
name: identity.name ?? "Unknown",
},
});
});
export const listNumbersAuth = convex
.query()
.use(authMiddleware)
.input({ count: v.number() })
.handler(async (context, input) => {
const numbers = await context.db
.query("numbers")
.order("desc")
.take(input.count);
return {
viewer: context.user.name, // user is available from middleware!
numbers: numbers.map((n) => n.value),
};
})
.public();Validation
See the Validation docs for a side-by-side comparison of all three approaches with live demos.
fluent-convex supports three flavors of input validation through the same .input() API:
- Property validators --
{ count: v.number() }(simplest) - Object validators --
v.object({ count: v.number() })(with.returns()support) - Zod schemas --
z.object({ count: z.number().min(1) })(via the Zod plugin)
Middleware
See the Middleware docs for detailed examples of both patterns.
There are two main middleware patterns:
- Context-enrichment -- adds new properties to the context (e.g.
ctx.user) - Onion (wrap) -- runs code before and after the handler (e.g. timing, error handling)
Reusable Chains & Callables
See the Reusable Chains docs for full examples with live demos.
Because the builder is immutable, you can stop the chain at any point and reuse that partial builder later. A builder with a .handler() but no .public() / .internal() is called a callable -- a fully-typed function you can:
- Call directly from inside other handlers (no additional Convex function invocation)
- Register as a standalone Convex endpoint
- Extend with more middleware and register multiple ways
// 1. Define a callable - NOT yet registered with Convex
const getNumbers = convex
.query()
.input({ count: v.number() })
.handler(async (ctx, args) => {
const rows = await ctx.db.query("numbers").order("desc").take(args.count);
return rows.map((r) => r.value);
});
// 2. Register it as a public query
export const listNumbers = getNumbers.public();
// 3. Call it directly from inside another handler - no additional function invocation!
export const getNumbersWithTimestamp = convex
.query()
.input({ count: v.number() })
.handler(async (ctx, args) => {
const numbers = await getNumbers(ctx, args); // <-- direct call
return { numbers, fetchedAt: Date.now() };
})
.public();
// 4. Register the same callable with different middleware
export const listNumbersProtected = getNumbers.use(authMiddleware).public();
export const listNumbersLogged = getNumbers.use(withLogging("logged")).public();The callable syntax is callable(ctx, args) -- the first argument passes the context (so the middleware chain runs with the right ctx), the second passes the validated arguments.
Plugins
Zod Plugin (fluent-convex/zod)
See the Zod Plugin docs for live demos including refinement validation.
The Zod plugin adds Zod schema support for .input() and .returns(), with full runtime validation including refinements (.min(), .max(), .email(), etc.).
npm install zod convex-helpersNote:
zodandconvex-helpersare optional peer dependencies offluent-convex. They're only needed if you use the Zod plugin.
Usage:
import { createBuilder } from "fluent-convex";
import { WithZod } from "fluent-convex/zod";
import { z } from "zod";
import type { DataModel } from "./_generated/dataModel";
const convex = createBuilder<DataModel>();
export const listNumbers = convex
.query()
.extend(WithZod) // Enable Zod support
.input(
z.object({
count: z.number().int().min(1).max(100), // Refinements enforced at runtime!
})
)
.returns(z.object({ numbers: z.array(z.number()) }))
.handler(async (context, input) => {
const numbers = await context.db.query("numbers").take(input.count);
return { numbers: numbers.map((n) => n.value) };
})
.public();Key features:
- Full runtime validation - Zod refinements (
.min(),.max(),.email(),.regex(), etc.) are enforced server-side. Args are validated before the handler runs; return values after. - Structural conversion - Zod schemas are automatically converted to Convex validators for Convex's built-in validation.
- Composable -
.extend(WithZod)preserves theWithZodtype through.use(),.input(), and.returns()chains. - Plain validators still work - You can mix Zod and Convex validators in the same builder chain.
Extensibility
See the Custom Plugins docs for a complete worked example with live demo.
You can extend the builder with your own plugins by subclassing ConvexBuilderWithFunctionKind and overriding the _clone() factory method.
Writing a Plugin
The _clone() method is called internally by .use(), .input(), and .returns() to create new builder instances. By overriding it, your plugin's type is preserved through the entire builder chain.
import {
ConvexBuilderWithFunctionKind,
type GenericDataModel,
type FunctionType,
type Context,
type EmptyObject,
type ConvexArgsValidator,
type ConvexReturnsValidator,
type ConvexBuilderDef,
} from "fluent-convex";
class MyPlugin<
TDataModel extends GenericDataModel = GenericDataModel,
TFunctionType extends FunctionType = FunctionType,
TCurrentContext extends Context = EmptyObject,
TArgsValidator extends ConvexArgsValidator | undefined = undefined,
TReturnsValidator extends ConvexReturnsValidator | undefined = undefined,
> extends ConvexBuilderWithFunctionKind<
TDataModel,
TFunctionType,
TCurrentContext,
TArgsValidator,
TReturnsValidator
> {
// Accept both builder instances (from .extend()) and raw defs (from _clone())
constructor(builderOrDef: any) {
const def =
builderOrDef instanceof ConvexBuilderWithFunctionKind
? (builderOrDef as any).def
: builderOrDef;
super(def);
}
// Override _clone() to preserve MyPlugin through the chain
protected _clone(def: ConvexBuilderDef<any, any, any>): any {
return new MyPlugin(def);
}
// Add custom methods
myCustomMethod(param: string) {
console.log("Custom method called with:", param);
return this;
}
}Usage:
export const myQuery = convex
.query()
.extend(MyPlugin)
.myCustomMethod("hello") // Custom method from plugin
.use(authMiddleware) // .use() preserves MyPlugin type
.input({ count: v.number() })
.handler(async (ctx, input) => { ... })
.public();Composing Multiple Plugins
Plugins can be composed with .extend():
export const myQuery = convex
.query()
.extend(MyPlugin)
.extend(WithZod) // WithZod overrides .input()/.returns() from MyPlugin
.myCustomMethod("hello")
.input(z.object({ count: z.number() }))
.handler(async (ctx, input) => { ... })
.public();Flexible Method Ordering
The builder API is flexible about method ordering, allowing you to structure your code in the way that makes the most sense for your use case.
Middleware After Handler
You can add middleware after defining the handler, which is useful when you want to wrap existing handlers with additional functionality:
export const getNumbers = convex
.query()
.input({ count: v.number() })
.handler(async (context, input) => {
return await context.db.query("numbers").take(input.count);
})
.use(authMiddleware) // Middleware added after handler
.public();Callable Builders
Before registering a function with .public() or .internal(), the builder is callable -- you can invoke it directly from other handlers (see Reusable Chains above) or use it in tests:
// A callable (not yet registered)
const getDouble = convex
.query()
.input({ count: v.number() })
.handler(async (context, input) => {
return { doubled: input.count * 2 };
});
// Call it from another handler
export const tripled = convex
.query()
.input({ count: v.number() })
.handler(async (ctx, input) => {
const { doubled } = await getDouble(ctx, input);
return { tripled: doubled + input.count };
})
.public();
// Or call it directly in tests
const mockContext = {} as any;
const result = await getDouble(mockContext, { count: 5 });
console.log(result); // { doubled: 10 }
// Register it when you also need it as a standalone endpoint
export const doubleNumber = getDouble.public();Method Ordering Rules
.returns()must be called before.handler().use()can be called before or after.handler().public()or.internal()must be called after.handler()and is required to register the function- Functions are callable before registration, non-callable after registration
- All exported functions must end with
.public()or.internal()- functions without registration will not be available in your Convex API
API
Methods
.query()- Define a Convex query.mutation()- Define a Convex mutation.action()- Define a Convex action.public()- Register the function as public (required to register).internal()- Register the function as internal/private (required to register).input(validator)- Set input validation (Convex validators).returns(validator)- Set return validation (Convex validators).use(middleware)- Apply middleware.createMiddleware(fn)- Create a middleware function.handler(fn)- Define the function handler.extend(plugin)- Extend the builder with a plugin class
Caveats
Circular types when calling api.* in the same file
When a function calls other functions via api.* in the same file, and those functions don't have explicit .returns() validators, TypeScript may report circular initializer errors (TS7022). This is a standard Convex/TypeScript limitation, not specific to fluent-convex. Workarounds:
- Add
.returns()to the called functions -- this gives them explicit return types, breaking the cycle - Move the calling function to a separate file
- Use
internal.*from a different module
Development
This is a monorepo using npm workspaces:
/packages/fluent-convex- The core library (includes the Zod plugin atfluent-convex/zod)/apps/example- Example Convex app/apps/docs- Interactive docs & showcase site (live)
Setup
npm installThis will install dependencies for all workspaces.
Building
npm run buildRunning tests
npm testRunning the example
cd apps/example
npm run devRunning the docs locally
npm run docs:devCredits
Borrowed heavily from oRPC and helped out by AI.
