superapp
BackendPermissions

Middleware

Run custom TypeScript before and after query execution — modify where clauses, transform input, restrict columns, and run side effects.

Chat in Claude

When declarative where clauses, validate rules, and defaults/overwrite are not enough, middleware lets you wrap the query execution with custom TypeScript. Middleware is defined inside an operation block. Every parameter is destructured — you work directly with user, db, where, input, and pass overrides to next() to control what gets executed.

permissions: {
  manage_tasks: {
    table: 'main.tasks',
    roles: ['member'],
    select: {
      columns: '*',
      where: { organization_id: { $eq: '$user.current_org_id' } },
      middleware: async ({ user, db, where }, next) => {
        // Before: tighten the where clause based on a dynamic lookup
        const activeTeams = await db
          .select({ id: teams.id })
          .from(teams)
          .innerJoin(teamMembers, eq(teamMembers.teamId, teams.id))
          .where(and(
            eq(teamMembers.userId, user.id),
            eq(teamMembers.status, 'active'),
          ))

        const rows = await next({
          where: { ...where, team_id: { $in: activeTeams.map(t => t.id) } },
        })

        // After: attach computed data before returning
        return rows.map(row => ({
          ...row,
          is_mine: row.assigned_to === user.id,
        }))
      },
    },
  },
}

How It Works

Middleware wraps the query with two hooks — before and after next(). You decide what runs at each stage:

select: {
  columns: '*',
  middleware: async ({ user, db, where, input, columns }, next) => {
    // BEFORE — runs before the query
    // Validate access, look up data, modify the request

    const rows = await next({
      where: { ...where, status: { $ne: 'archived' } },
    })

    // AFTER — runs after the query
    // Transform results, run side effects, enrich rows

    return rows
  },
}
StageWhat happens
Before next()Validate access, look up data, compute overrides, modify the request
next(overrides)Executes the query — pass overrides to change what runs
After next()Transform results, run side effects, enrich with computed data
Skip next()Throw to deny the request entirely
  6. Permission Check — where, validate, default, overwrite applied
    |
    v
  6a. middleware (before next) <-- modify request here
    |
    v
  7. Query Execution <-- next() triggers this
    |
    v
  7a. middleware (after next) <-- transform results here
    |
    v
  8. Audit Log

Parameters

Every parameter is destructured — no wrapper object:

middleware: async ({ user, db, table, operation, columns, query, input, where }, next) => {
ParameterTypeDescription
userUserSessionResolved session (same as $user.* in where clauses)
dbDrizzleInstanceDrizzle query builder — run any query
tablestringTarget table, e.g. 'main.orders'
operation'select' | 'insert' | 'update' | 'delete'Detected operation
columnsstring[]Columns the client is requesting or writing
query{ sql: string, params: any[] }The client's raw parameterized SQL
inputRecord<string, any> | undefinedRequest body values (writes only)
whereRecord<string, any> | undefinedResolved where clause (after merging and $user.* substitution)

Modifying the Request

Pass overrides to next() to change what gets executed. This is how you modify the incoming request before it hits the database.

Evaluate and Modify Where Clauses

The where parameter contains the fully resolved where clause — after declarative where, $user.* substitution, and sql fragments are merged. You can read it, extend it, or replace it entirely:

// Extend: add conditions to the existing where
middleware: async ({ user, db, where }, next) => {
  const teamIds = await getActiveTeamIds(db, user.id)

  return next({
    where: {
      ...where,
      team_id: { $in: teamIds },
      status: { $ne: 'archived' },
    },
  })
},
// Replace: swap the where clause entirely based on role
middleware: async ({ user, where }, next) => {
  if (user.roles.includes('admin')) {
    // Admins see everything in their org
    return next({
      where: { organization_id: { $eq: user.current_org_id } },
    })
  }
  // Everyone else keeps the original restrictive where
  return next()
},
// Conditional: evaluate the incoming where and decide
middleware: async ({ user, db, where }, next) => {
  // If the client is filtering by a specific project, verify access
  if (where?.project_id) {
    const hasAccess = await db.query.projectMembers.findFirst({
      where: and(
        eq(projectMembers.projectId, where.project_id.$eq ?? where.project_id),
        eq(projectMembers.userId, user.id),
      ),
    })
    if (!hasAccess) {
      throw new PermissionError('Not a member of this project')
    }
  }
  return next()
},

Transform Input

Modify the request body before it's written to the database. Useful for normalizing values, computing derived fields, or injecting server-side data:

// Normalize and enrich input on insert
middleware: async ({ user, db, input }, next) => {
  const product = await db.query.products.findFirst({
    where: eq(products.id, input.product_id),
  })
  if (!product) throw new PermissionError('Product not found')

  return next({
    input: {
      ...input,
      email: input.email?.toLowerCase().trim(),
      unit_price: product.price,
      total: product.price * input.quantity,
    },
  })
},
// Conditionally modify input on update
middleware: async ({ user, input }, next) => {
  if (input?.status === 'published') {
    return next({
      input: {
        ...input,
        published_at: new Date().toISOString(),
        published_by: user.id,
      },
    })
  }
  return next()
},

Restrict Columns

Narrow the column list based on runtime conditions — subscriptions, roles, or any dynamic lookup:

// Free users see basic columns, paid see everything
middleware: async ({ user, db }, next) => {
  const sub = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.organizationId, user.current_org_id),
    columns: { plan: true },
  })

  if (sub?.plan === 'free') {
    return next({ columns: ['id', 'name', 'status', 'created_at'] })
  }
  return next()
},

Override Summary

OverrideEffect
whereReplaces the where clause used in the WHERE
inputReplaces the input data for writes
columnsReplaces the column list
dbReplaces the database instance (use for transactions)

If you call next() with no arguments, the query executes unchanged.

Transforming Results

Code after next() runs once the query completes. Use it to transform, enrich, or redact rows before they reach the client:

// Redact PII for non-clinical staff
middleware: async ({ user }, next) => {
  const rows = await next()

  if (!user.roles.includes('clinical')) {
    return rows.map(row => ({
      ...row,
      email: row.email ? `${row.email[0]}***@***` : null,
      phone: row.phone ? '***-****' : null,
      ssn: row.ssn ? '***-**-' + row.ssn.slice(-4) : null,
    }))
  }
  return rows
},
// Enrich rows with computed data from another table
middleware: async ({ db }, next) => {
  const rows = await next()
  if (rows.length === 0) return rows

  const counts = await db
    .select({ orderId: orderItems.orderId, itemCount: count() })
    .from(orderItems)
    .where(inArray(orderItems.orderId, rows.map(r => r.id)))
    .groupBy(orderItems.orderId)

  const countMap = new Map(counts.map(c => [c.orderId, c.itemCount]))
  return rows.map(row => ({
    ...row,
    item_count: countMap.get(row.id) ?? 0,
  }))
},

Transactions

Wrap next() in db.transaction() and pass the transaction as db — if anything throws after the query, everything rolls back:

middleware: async ({ user, db, input }, next) => {
  return db.transaction(async (tx) => {
    // The main query runs inside the transaction
    const rows = await next({ db: tx })

    // This runs after the query — if it throws, both the
    // main query and this update are rolled back
    await tx.insert(auditLogs).values({
      userId: user.id,
      action: 'order_created',
      orderId: rows[0]?.id,
      timestamp: new Date(),
    })

    return rows
  })
},

This is especially useful for writes that need atomic side effects — stock decrements, balance updates, counter increments, or any operation where partial completion is not acceptable.

Examples

E-Commerce: Order Creation with Stock Validation

An online store lets customers place orders through a dashboard built with Drizzle on the client. The client sends an INSERT with just the product and quantity — but the server needs to verify stock availability, compute the total price from the catalog, decrement inventory, and queue a warehouse fulfillment job. All of this must happen atomically: if the warehouse job fails to insert, the order and stock change must roll back too.

create_order: {
  table: 'main.orders',
  roles: ['customer'],
  insert: {
    columns: ['product_id', 'quantity', 'shipping_address'],
    overwrite: {
      customer_id: '$user.id',
      organization_id: '$user.current_org_id',
      status: 'pending',
    },
    validate: {
      quantity: { $gte: 1, $lte: 100 },
    },
    middleware: async ({ user, db, input }, next) => {
      // Before: validate product and check stock
      const product = await db.query.products.findFirst({
        where: and(
          eq(products.id, input.product_id),
          eq(products.organizationId, user.current_org_id),
        ),
      })
      if (!product) {
        throw new PermissionError('Product not found')
      }
      if (product.stock < input.quantity) {
        throw new PermissionError(
          `Only ${product.stock} units available`,
        )
      }

      // Execute atomically: insert order + decrement stock + queue job
      return db.transaction(async (tx) => {
        const rows = await next({
          db: tx,
          // Before: enrich input with server-computed values
          input: {
            ...input,
            unit_price: product.price,
            total: product.price * input.quantity,
          },
        })

        // After: side effects inside the transaction
        await tx
          .update(products)
          .set({ stock: sql`stock - ${input.quantity}` })
          .where(eq(products.id, input.product_id))

        await tx.insert(warehouseJobs).values({
          orderId: rows[0].id,
          productId: input.product_id,
          quantity: input.quantity,
          status: 'queued',
        })

        return rows
      })
    },
  },
}

Project Management: Dynamic Where from Team Membership

A project management app where users belong to multiple teams. Tasks should only be visible to members of the team that owns them. The team membership is complex — users can be active or inactive, and membership can expire — so a simple declarative where with $user.team_ids isn't enough. The middleware evaluates the user's current memberships and injects a dynamic where clause.

view_tasks: {
  table: 'main.tasks',
  roles: ['member'],
  select: {
    columns: '*',
    middleware: async ({ user, db, where }, next) => {
      // Before: look up active teams and inject into where
      const activeTeams = await db
        .select({ id: teams.id })
        .from(teams)
        .innerJoin(teamMembers, eq(teamMembers.teamId, teams.id))
        .where(
          and(
            eq(teamMembers.userId, user.id),
            eq(teamMembers.status, 'active'),
            gt(teamMembers.expiresAt, new Date()),
          ),
        )

      if (activeTeams.length === 0) {
        throw new PermissionError('No active team memberships')
      }

      return next({
        where: {
          ...where,
          team_id: { $in: activeTeams.map(t => t.id) },
        },
      })
    },
  },
}

Multi-Tenant: Cascading Delete with Usage Recalculation

A multi-tenant platform tracks per-organization resource usage in a denormalized organizations.project_count column for fast billing lookups. When a user deletes a project, the count must be recalculated. Both the delete and the count update must succeed or fail together.

delete_project: {
  table: 'main.projects',
  roles: ['admin'],
  delete: {
    where: {
      organization_id: { $eq: '$user.current_org_id' },
    },
    middleware: async ({ user, db }, next) => {
      return db.transaction(async (tx) => {
        const rows = await next({ db: tx })

        // After: recalculate count — if this fails, the delete rolls back
        const [{ total }] = await tx
          .select({ total: count() })
          .from(projects)
          .where(eq(projects.organizationId, user.current_org_id))

        await tx
          .update(organizations)
          .set({ projectCount: total })
          .where(eq(organizations.id, user.current_org_id))

        return rows
      })
    },
  },
}

Approval Workflow: Role-Based Status Transitions

A procurement system where purchase orders go through an approval workflow: draft -> submitted -> approved -> fulfilled. The middleware evaluates the incoming input to enforce who can set which status, based on the current state and the user's role.

manage_purchase_orders: {
  table: 'main.purchase_orders',
  roles: ['member', 'manager'],
  select: {
    columns: '*',
    where: { organization_id: { $in: '$user.org_ids' } },
  },
  update: {
    columns: '*',
    where: { organization_id: { $in: '$user.org_ids' } },
    validate: {
      status: { $in: ['draft', 'submitted', 'approved', 'fulfilled'] },
    },
    overwrite: { updated_by: '$user.id' },
    middleware: async ({ user, db, input, where }, next) => {
      // Before: evaluate the incoming request and enforce transitions
      if (!input?.status) return next()

      const [order] = await db
        .select({ status: purchaseOrders.status, createdBy: purchaseOrders.createdBy })
        .from(purchaseOrders)
        .where(eq(purchaseOrders.id, input.id))
        .limit(1)

      if (!order) throw new PermissionError('Order not found')

      // draft -> submitted: only the creator
      if (input.status === 'submitted' && order.status === 'draft') {
        if (order.createdBy !== user.id) {
          throw new PermissionError('Only the creator can submit this order')
        }
      }

      // submitted -> approved: only managers
      if (input.status === 'approved' && order.status === 'submitted') {
        if (!user.roles.includes('manager')) {
          throw new PermissionError('Only managers can approve orders')
        }
      }

      // No skipping steps
      const allowedTransitions = {
        draft: ['submitted'],
        submitted: ['approved', 'draft'],
        approved: ['fulfilled'],
      }
      if (!allowedTransitions[order.status]?.includes(input.status)) {
        throw new PermissionError(
          `Cannot transition from ${order.status} to ${input.status}`,
        )
      }

      return next()
    },
  },
}

Combining with Declarative Rules

Middleware works alongside where clauses, validate rules, defaults, overwrite, and raw SQL. The engine applies them in order — declarative rules run first, then middleware wraps the execution:

1. where       -> WHERE clause injected
2. validate    -> Input values validated
3. default     -> Missing values filled
4. overwrite   -> Server values forced
5. sql         -> SQL fragment appended to WHERE
6. middleware   -> Your TypeScript wraps the execution (before + after)
7. Query executes (inside next())

Use declarative rules for the common cases and middleware for the edge cases:

manage_orders: {
  table: 'main.orders',
  roles: ['member', 'manager'],
  select: {
    columns: '*',
    where: { organization_id: { $in: '$user.org_ids' } },
  },
  insert: {
    columns: ['amount', 'status', 'customer_id'],
    validate: { amount: { $gte: 0 } },
    overwrite: { created_by: '$user.id' },
  },
  update: {
    columns: ['amount', 'status'],
    where: { organization_id: { $in: '$user.org_ids' } },
    validate: { amount: { $gte: 0 } },
    overwrite: { updated_by: '$user.id' },
    middleware: async ({ user, db, input }, next) => {
      // Before: enforce business rules on the incoming request
      if (input?.status === 'approved') {
        const membership = await db.query.orgMembers.findFirst({
          where: and(
            eq(orgMembers.userId, user.id),
            eq(orgMembers.orgId, user.current_org_id),
          ),
          columns: { role: true },
        })
        if (membership?.role !== 'manager') {
          throw new PermissionError('Only managers can approve orders')
        }
      }

      const rows = await next()

      // After: strip sensitive fields for non-managers
      if (!user.roles.includes('manager')) {
        return rows.map(({ internal_notes, ...row }) => row)
      }
      return rows
    },
  },
}

Error Handling

Throw PermissionError to reject with 403 Forbidden:

middleware: async (params, next) => {
  throw new PermissionError('Reason shown in error response')
},

Any other thrown error returns 500 Internal Server Error and is logged but not exposed to the client. When using transactions, any throw inside db.transaction() automatically rolls back all queries within that transaction.

When to Use

Use CaseRecommendation
Static row filteringUse where clauses
Value validationUse validate
Fill missing valuesUse default
Force server valuesUse overwrite
Date math, computed WHEREUse raw SQL
Dynamic where from lookupsUse middleware with next({ where })
Input normalization / enrichmentUse middleware with next({ input })
Runtime column restrictionUse middleware with next({ columns })
Result transformation / redactionUse middleware (after next())
Business rule enforcementUse middleware (before next())
Atomic writes with side effectsUse middleware with db.transaction()

Prefer declarative rules when possible. Use middleware only when the built-in mechanisms cannot express your logic.

On this page