Skip to main content
Middleware is reusable logic that runs around every function in a group—the Confect analog of 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 in confect/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.
Keep implementations out of *.spec.ts modules. Every spec is reachable from _generated/refs.ts, which your client imports, so a spec’s entire import graph is bundled into the browser—including any middleware implementation co-located with its declaration, and everything it closes over (table names, index names, your authorization logic). confect codegen fails when a module a spec reaches value-imports @confect/server; when a spec needs a server type, reach for it with import type, which costs the client nothing.

Declaring a middleware

Declare a middleware with MiddlewareSpec.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
Default-export the middleware and name the errors and services it provides as named exports, so both halves of the pair—and the groups that attach it—import it the same way. Both 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 required functionTypes 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 with GroupSpec.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
Several attachments are rejected at the type level (and at runtime), each with a message naming the problem:
  • 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 functionTypes don’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.
Middleware does not propagate to subgroups: 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
The same rules apply as at the group level, at the same authoring sites: attaching a middleware whose 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 lazy options 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
Attach 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
For a group-wide role policy, use 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
Provide this implementation once in the group’s impl layer, alongside the 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.
Options schemas and attachment values are reachable from client refs. Keep them and any resolver functions client-safe: do not put secrets, server-only imports, or privileged lookup logic in options. Keep authorization and database reads in the impl.

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:
Both role checks must pass before the handler runs, so only an 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 the Config 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 requires to be provided by middleware attached to that group earlier—attachment order is chain order, so the check happens right at GroupSpec.middleware.
  • A function-level middleware’s requires may be satisfied by the group’s middleware, which the function spec can’t see, so the whole-group check happens at GroupImpl.make: every function’s middleware must have its requires provided by some middleware covering that function.
Like 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 requiring CurrentUser 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
Since handlers depend only on the service, unit tests don’t need middleware at all—provide a stub directly with 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 contains invocation, 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.fail with 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.
For the common “run something, provide a service” shape, use the MiddlewareImpl.provides shorthand, passing the runtime tag for the spec’s type-level provides:
confect/middleware/RequireUser.impl.ts
The general wrap form takes the downstream effect explicitly—here, actions-only timing middleware that runs code on both sides of the handler:
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 with MiddlewareImpl.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 leaves QueryRunner 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
A middleware implementation is just a layer—share one across groups by providing it to each group’s pipeline.

Middleware errors at call sites

A middleware’s error 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:
Middleware on Confect’s HTTP API is separate: HTTP endpoints use Effect’s own HttpApi middleware machinery.