Middleware extends ctx with typed fields. Workflows declare them as an array — extensions accumulate.
import { createMiddleware } from '@tanstack/workflow-core'
const requireUser = createMiddleware().server<{
user: { id: string; email: string }
}>(async ({ next }) => {
const user = await loadUser()
if (!user) throw new Error('unauthorized')
return next({ context: { user } })
})import { createMiddleware } from '@tanstack/workflow-core'
const requireUser = createMiddleware().server<{
user: { id: string; email: string }
}>(async ({ next }) => {
const user = await loadUser()
if (!user) throw new Error('unauthorized')
return next({ context: { user } })
})The generic on .server<...> is the extension shape. TS uses it to add ctx.user everywhere the middleware is registered.
const wf = createWorkflow({ id: 'wf' })
.middleware([requireUser])
.handler(async (ctx) => {
ctx.user.id // typed
})const wf = createWorkflow({ id: 'wf' })
.middleware([requireUser])
.handler(async (ctx) => {
ctx.user.id // typed
})const traced = createMiddleware().server<{ trace: Trace }>(async ({ next }) => {
const trace = startTrace()
try {
return await next({ context: { trace } })
} finally {
trace.end()
}
})const traced = createMiddleware().server<{ trace: Trace }>(async ({ next }) => {
const trace = startTrace()
try {
return await next({ context: { trace } })
} finally {
trace.end()
}
})next is called once. Code before runs pre-handler; code after runs post.
const requireUser = createMiddleware().server<{ user: User }>(
async ({ next }) => next({ context: { user: await loadUser() } }),
)
// Reaches ctx.user — type the inbound ctx with the generic on createMiddleware.
const requirePro = createMiddleware<{ user: User }>().server<{ tier: 'pro' }>(
async ({ ctx, next }) => {
if (ctx.user.tier !== 'pro') throw new Error('pro required')
return next({ context: { tier: 'pro' } })
},
)
createWorkflow({ id: 'wf' })
.middleware([requireUser, requirePro]) // order matters
.handler(async (ctx) => {
ctx.user // from requireUser
ctx.tier // from requirePro
})const requireUser = createMiddleware().server<{ user: User }>(
async ({ next }) => next({ context: { user: await loadUser() } }),
)
// Reaches ctx.user — type the inbound ctx with the generic on createMiddleware.
const requirePro = createMiddleware<{ user: User }>().server<{ tier: 'pro' }>(
async ({ ctx, next }) => {
if (ctx.user.tier !== 'pro') throw new Error('pro required')
return next({ context: { tier: 'pro' } })
},
)
createWorkflow({ id: 'wf' })
.middleware([requireUser, requirePro]) // order matters
.handler(async (ctx) => {
ctx.user // from requireUser
ctx.tier // from requirePro
})import type { WorkflowCtx } from '@tanstack/workflow-core'
async function sendReceipt(
ctx: WorkflowCtx<{ user: User }>,
amount: number,
) {
await ctx.step('send-receipt', () => mailer.send(ctx.user.email, amount))
}import type { WorkflowCtx } from '@tanstack/workflow-core'
async function sendReceipt(
ctx: WorkflowCtx<{ user: User }>,
amount: number,
) {
await ctx.step('send-receipt', () => mailer.send(ctx.user.email, amount))
}Pass the typed ctx to the helper — the constraint documents which middleware fields must be in scope.