convex-helpers custom functions. A middleware runs per invocation, after arguments are decoded and before the handler. It can provide Effect services to downstream handlers (a CurrentUser, say), inspect the decoded arguments, short-circuit with a typed error that surfacesâdecodedâat every call site, and run logic after the handler completes.
Like functions, middleware is split into a spec and impl: the spec half declares the middlewareâs client-safe interface (its name, the service it provides, how it can fail), while the impl half holds the server-only logic (database lookups, identity resolution). Because the interface lives in the spec, a middlewareâs errors join the error unions of the functions it covers, and clients decode them with no extra wiring.
Where middleware lives
Middleware goes inconfect/middleware/, one middleware per pair of modules, named after the middleware itself:
confect/middleware/ is reserved: Confect does not scan it for function groups, so the *.spec.ts/*.impl.ts pair there means âmiddlewareâ rather than âgroupâ. A middleware is shared, so it belongs to no single groupâputting it in a groupâs spec would force unrelated groups to import from it.
Declaring a middleware
Declare a middleware withMiddlewareSpec.MiddlewareSpec. The Config type parameter has two optional slots: provides names the service the middleware provides to handlers, and requires names services it consumes from earlier middleware. Both are type-level only; runtime tags are passed to the impl separately. The optional error schema declares how the middleware can fail, lazily like a function specâs error. To accept attachment options, declare an options schema in the constructor as described in Parameterizing a policy.
confect/middleware/RequireUser.spec.ts
provides and error are optional: a middleware may only observe (logging, timing), only guard (short-circuit without providing anything), or both provide and fail.
Function types
A middleware declares which function types it may cover with the requiredfunctionTypes optionâa boolean flag for each of query, mutation, and action (Node actions count as action). Every flag must be specified, so each spec states its coverage outright, the way Convex itself keeps the three function types explicit and separate. The flags must be literal true or falseâthey determine the declared function types at the type level, so a computed boolean is rejected with a type error, as is declaring all three false (a middleware attachable to nothing). Attaching a middleware to a group is a type error unless every functionâs type is among the middlewareâs declared function types, so the RequireUser above (action: false) only fits groups of queries and mutations, and a mutation-only middleware only fits all-mutation groups.
The declared function types also determine which services the middlewareâs implementation may useâsee below.
Attaching to a group
Attach middleware in the group spec withGroupSpec.middleware. Attachment is declarative and order-independent with respect to addFunction: the middleware covers every function the group declares, whether added before or after the call.
confect/notes.spec.ts
- Duplicates without optionsâattaching the same middleware key without an options schema to a group twice. Middleware with options can be repeated with non-equivalent values; see Repeating a policy.
- Uncovered function typesâattaching a middleware whose
functionTypesdonât include some declared functionâs type (or adding such a function later). - Plain Convex functions of a matching typeâtheir raw handlers pass through Confect untouched, so a middleware could not actually cover them; rejecting the attachment prevents a silent policy hole. A plain Convex function whose type the middleware doesnât declare is fine.
GroupSpec.middleware covers only the declaring groupâs own functions.
When a group attaches more than one middleware, they run in attachment orderâthe first-attached middleware is outermost. If an earlier middleware short-circuits, later middleware and the handler never run.
Attaching to a single function
When one function needs a stricter check than its group, attach middleware to the function spec itself with.middleware(). Function-level middleware runs after (inside) the group-attached chain, immediately around the handler, and its error joins only that functionâs error unionâthe groupâs other functions are unaffected. The RequireRole policy defined below restricts a function to permitted user roles:
confect/notes.spec.ts
functionTypes donât include the functionâs type, attaching to a plain Convex function, or repeating a middleware key without an options schemaâincluding once at each level, in either orderâare all type errors. Middleware with options can be repeated within either level or across both; equivalent options are rejected during validation. Implementations are provided to the groupâs impl layer exactly like group-level ones, and GroupImpl.finalize demands them just the same.
Note that a failed mutation still rolls back its whole transaction: if a function-level middleware short-circuits after a group middleware has written something, those writes are rolled back with it.
Parameterizing a policy
Declare a lazyoptions schema in the middleware constructor to reuse one policy with different attachment values. The schemaâs Type determines the options type; Config only declares provides and requires.
A typical use is requiring a signed-in user with one of several permitted roles. Keep the user lookup in RequireUser, then let a configurable RequireRole consume the CurrentUser it provides. For this example, assume the users table has a trusted role field defined with Schema.Literals(["admin", "editor", "viewer"]):
confect/middleware/RequireRole.spec.ts
RequireUser first so it supplies the loaded user, then enable permitted roles in the second argument to .middleware(). One policy now covers both editor-or-admin reads and admin-only writes:
confect/notes.spec.ts
GroupSpec.make().middleware(RequireUser).middleware(RequireRole, { admin: true }); it covers every function in that group.
The implementation reads the trusted user from CurrentUser, not from caller-supplied function arguments:
confect/middleware/RequireRole.impl.ts
RequireUser implementation and the function implementations. The role flags are specific to each attachment, not to the registered implementation. A user is allowed only when their roleâs flag is true; omitted or false flags deny access, and {} denies everyone. Missing users fail with NotSignedIn from RequireUser, while signed-in users outside the permitted roles fail with AccessDenied.
The second argument is required and typed from the schemaâs Type. Middleware without an options schema still uses .middleware(Spec) with no second argument. Options are validated against the schemaâs type side, not its encoded input, during codegen and server registration. Confect does not decode or coerce them: if a schema transforms strings into numbers, pass a number, not a string.
Pass options as a schema factory (() => schema), not as a schema value.
Both MiddlewareImpl.make and MiddlewareImpl.makeByFunctionType receive (effect, { options, invocation }) when the middleware declares an options schema. The typed options field contains the attachment value; invocation contains name, functionType, functionVisibility, and decoded args. Middleware without an options schema receives only { invocation }: options is absent from both the context type and the runtime object. A declared schema that accepts undefined still receives an options field, even when its value is undefined. Destructure only what you need: (effect, { options }) reads the role flags without binding invocation metadata.
The same options mechanism also supports resource argument names, flags, and client-safe resolver functions. Use MiddlewareImpl.make rather than the provides shorthand when producing a service depends on the attachment options.
The middlewareâs declared error still determines the client error union, independently of its options. provides and requires also remain fixed for a spec: an option does not change a handlerâs service types. For a tolerateMissing policy, declare a service containing an Option and let the implementation decide whether to provide None or fail.
Repeating a policy
You can attach the same middleware spec more than once with non-equivalent options: to a group, to a function, or once at each level. A key must still identify one spec declaration and one registered implementation. Each attachment runs separately, with all group attachments first and then all function attachments, in their respective attachment order:admin user can call deleteAll in this example. Repeated guards form a conjunction, not a union of allowed policies: two role guards mean the caller must satisfy both, not either. An earlier short-circuit still prevents the remaining attachments and handler from running.
Attachments with the same middleware key must have non-equivalent options according to the options schema. Equivalent options are rejected by confect codegen and by server registration, including across the group/function boundary. TypeScript does not check this equivalence. Middleware without an options schema still rejects duplicate keys immediately, both at the type level and at runtime.
Equality follows the options schema rather than evaluating the policyâs behavior. In this example, object key order does not matter, but an omitted flag differs from an explicit false, even though both deny that role. Use Schema.overrideToEquivalence on the options schema if your policy needs custom equality semantics.
If repeated middleware provides the same service tag, the inner provider shadows the outer value for downstream middleware and the handler. Once the nested effect completes, the outer middleware sees its original service context again. Repetition does not change the specâs error union or its provides and requires type sets, and it does not relax function-type restrictions or propagate middleware to subgroups.
Depending on another middleware
A middleware can consume a service provided by middleware that runs earlier in the chain. Declare the dependency in theConfig type parameterâs requires slot; the implementationâs environment then includes it alongside the ctx services. The RequireRole example declares { requires: CurrentUser }, so its implementation can yield the user supplied by RequireUser.
The spec imports CurrentUser with import type: it is only named in a type position there, so the import is erased and the declaration stays client-safe. The implementation imports the runtime service tag to read the user.
Satisfaction is checked where the ordering is known:
- Attaching a middleware to a group requires its
requiresto be provided by middleware attached to that group earlierâattachment order is chain order, so the check happens right atGroupSpec.middleware. - A function-level middlewareâs
requiresmay be satisfied by the groupâs middleware, which the function spec canât see, so the whole-group check happens atGroupImpl.make: every functionâs middleware must have itsrequiresprovided by some middleware covering that function.
provides, requires is type-level only, so ordering within one functionâs own middleware list cannot be checkedâattach a function-level middleware after its same-level provider, or the missing service surfaces as a defect at runtime.
Using provided services in handlers
Handlers of covered functions consume the provided service like any other. This is the type-safety contract: a handler requiringCurrentUser type-checks exactly when a middleware providing it is attached to the groupâremove the .middleware(RequireUser) call and the handler below stops compiling.
confect/notes.impl.ts
Effect.provideService(CurrentUser, { user: fakeUser }).
Implementing a middleware
A middleware implementation wraps the downstream effect (any remaining middleware plus the handler). Its second argument always containsinvocation, with the covered functionâs name, functionType, functionVisibility, and decoded args. It also contains options when the middleware declares an options schema. The implementation decides whether and how to run the effect:
- Provide the declared service to it with
Effect.provideServiceâthe types require this (or never running the effect at all): the downstream effectâs environment carries the provided service as an obligation the implementation must discharge. - Short-circuit by returning
Effect.failwith the declared error instead of running the effect. - Observe by running the effect and adding logic before or after. The handlerâs result is opaque to middlewareâit can be passed along but not read or replacedâand errors the middleware doesnât declare pass through untouched.
MiddlewareImpl.provides shorthand, passing the runtime tag for the specâs type-level provides:
confect/middleware/RequireUser.impl.ts
Convex freezes time throughout each query or
mutation.
Clock.currentTimeMillis reads that frozen timestamp, so this middleware
would report 0ms there regardless of the actual duration. Use it only for
actions. Reading the clock in a query also affects caching, but that is a
separate concern from measuring elapsed time.Services available to an implementation
An implementation provided withMiddlewareImpl.make uses one strategy for every function type the middleware declares, so its environment is limited to the services available in all of those function types:
Per-function-type implementations
The all-function-types intersection leavesQueryRunner as the only database route, but Convex best practices say to use ctx.runQuery sparingly in queries and mutations. So for database-touching middleware that should also cover actionsâsay, extending RequireUser above to cover all three function types (flipping its action flag to true)âimplement per function type with MiddlewareImpl.makeByFunctionType instead: each entry gets that function typeâs full service set. Read directly in queries and mutations, and call an internal query (defined in a middleware-free group) in actions, where runQuery is the only route to the database:
Providing to the group layer
Provide the middleware implementation to the groupâs impl layer like any function implementation.GroupImpl.finalize only typechecks once every attached middlewareâs implementation has been provided, and confect codegen reports a missing one by name.
confect/notes.impl.ts
Middleware errors at call sites
A middlewareâserror schema joins the error union of every function it covers, alongside the functionâs own error schema. Callers consume the union exactly as described in Error Handlingânothing changes on the client:
HttpApi middleware machinery.