Skip to content

Middleware

Middleware is code that runs before your route handler. Think of it as a pipeline: Request enters, passes through layers, reaches the handler, Response comes back through the layers.

The Onion Model

If you have used Express middleware, you know the linear model: middleware runs in order, each calls next(), and that is it. Juice uses an onion model: each middleware wraps the next layer. Code before next() runs on the way in (request phase). Code after next() runs on the way out (response phase).

Request
  --> root middleware.ts         (before next)
    --> admin/middleware.ts      (before next)
      --> admin/dashboard.tsx    (route handler)
    <-- admin/middleware.ts      (after next -- can modify response)
  <-- root middleware.ts         (after next -- can add headers)
Response

Middleware files are discovered per directory and chain from root to leaf. Each middleware runs before all routes in its directory and subdirectories. Return a Response from any layer to short-circuit (skip the remaining layers).

Coming from Next.js: Next.js has a single middleware.tsat the project root that runs on the edge. Juice has per-directory middleware that runs on the same runtime as your routes. If you want auth only on /admin/*, put middleware.ts in app/routes/admin/. No path matching regex needed.

Typed Context: Passing Data from Middleware to Components

Here is the problem: your auth middleware validates a session and has the user object. Your page component needs that user object. You cannot pass it through props because middleware does not render. You cannot use a global because requests are concurrent.

Juice solves this with request-scoped context: setContext attaches a value to the Request object, and getContext reads it later in any component that has the same Request reference.

// app/context-keys.ts
import { createContextKey } from '@cmj/juice/runtime';

// The key carries the type. No casting needed when reading.
export const userKey = createContextKey<{ id: string; name: string; role: string }>('user');
export const dbKey = createContextKey<Database>('db');

createContextKey<T>(name) creates a typed key. The nameis a string used for debugging. The type parameter T is the value type. When you call setContext(req, userKey, value), TypeScript enforces thatvalue matches T. When you call getContext(req, userKey), the return type is T | undefined. Zero runtime overhead -- it is just a string lookup with compile-time branding.

You can also use plain strings as keys, but you lose type inference:

setContext(req, 'user', user);         // value is unknown when reading
const user = getContext(req, 'user');  // type: unknown

Real Auth Pattern

// app/routes/admin/middleware.ts
import { setContext, redirect } from '@cmj/juice/runtime';
import { userKey } from '../../context-keys';

export default async function authMiddleware(
  req: Request,
  next: () => Promise<Response>,
) {
  // Parse session from cookie
  const cookieHeader = req.headers.get('cookie') ?? '';
  const cookies = Object.fromEntries(
    cookieHeader.split(';').map(c => c.trim().split('='))
  );
  const sessionId = cookies['sid'];

  if (!sessionId) {
    redirect('/login', 303);
  }

  const user = await db.sessions.findUser(sessionId);
  if (!user) {
    redirect('/login', 303);
  }

  // Attach user to request context
  setContext(req, userKey, user);

  // Continue to the route handler
  return next();
}
// app/routes/admin/dashboard.tsx
import { getContext } from '@cmj/juice/runtime';
import { userKey } from '../../context-keys';

export default function Dashboard({ request }: { request: Request }) {
  const user = getContext(request, userKey);

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {user?.name}. Role: {user?.role}</p>
    </div>
  );
}

Real CORS Pattern

// app/routes/api/middleware.ts
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com'];

export default async function corsMiddleware(
  req: Request,
  next: () => Promise<Response>,
) {
  const origin = req.headers.get('origin') ?? '';
  const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : '';

  // Handle preflight
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': allowedOrigin,
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  const response = await next();

  // Add CORS headers to all API responses
  response.headers.set('Access-Control-Allow-Origin', allowedOrigin);
  return response;
}

Request Logging

A practical example of the onion model: log the request on the way in and the response on the way out, including timing.

// app/routes/middleware.ts
export default async function loggingMiddleware(
  req: Request,
  next: () => Promise<Response>,
) {
  const start = performance.now();
  const response = await next();
  const ms = (performance.now() - start).toFixed(1);
  const url = new URL(req.url);

  console.log(`${req.method} ${url.pathname} ${response.status} ${ms}ms`);
  return response;
}

Database Connection Sharing

Do not create a database connection per request. Create a pool once in server.ts and share it via context.

// server.ts
import { createRouter, setContext } from '@cmj/juice/runtime';
import { dbKey } from './app/context-keys';

const pool = createPool({ connectionString: process.env.DATABASE_URL });

// Option 1: Use middleware
// app/routes/middleware.ts
export default async function dbMiddleware(
  req: Request,
  next: () => Promise<Response>,
) {
  setContext(req, dbKey, pool);
  return next();
}

// Option 2: Use onBeforeRequest for simpler setup
createRouter(manifest, {
  onBeforeRequest: (req) => {
    setContext(req, dbKey, pool);
  },
});

Why not just import the pool directly? You can. If your pool is a singleton module, importing it works fine. Context is useful when you want the pool to be testable (inject a mock in tests) or when different route groups need different databases.

When NOT to Use Middleware

If the logic only applies to one route, put it in the route handler. Middleware is for cross-cutting concerns: authentication, logging, rate limiting, CORS. Putting business logic in middleware makes it invisible to the route -- you have to trace the directory tree to understand what happens before your route runs.

Coming from Express: in Express, everything is middleware. Auth, body parsing, error handling, even the route handler itself. This leads to middleware stacks 20 layers deep where order matters but is not obvious. Juice's per-directory model constrains this: middleware applies to the routes in its directory, and the onion model makes the execution order explicit.