Framework guides
Integrate Nodora into Next.js, Express, or Hono using middleware
One common place for a Nodora rule evaluation in a web framework is in middleware: evaluate a rule on the way into a route, attach the result to the request, and either let it through or short-circuit with a response. The evaluator runs in-process, so this stays on the hot path without a network hop.
That said, how you wire Nodora into your app is up to you. The evaluator is just a function call: you can put it in a route handler, a service layer, a background job, a server action, or wherever fits the situation. Middleware is convenient for cross-cutting concerns like access checks or feature gating, but it isn't the only valid shape. Use it where it helps, and reach for a different structure when your case calls for one.
The examples below all use middleware because it's the most concise way to show evaluation on the hot path, and the pattern is identical across frameworks — only the syntax changes. Pick the route (self-managed or managed) that fits how you want to handle rule storage, and the snippets plug in the same way.
Self-managed with @nodora/js
Compile rulesets to NIR ahead of time, create the evaluator once at module load, and wrap it in a middleware factory.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { createEvaluator } from "@nodora/js";
import program from "@/rules/access.json";
const evaluatorPromise = createEvaluator(JSON.stringify(program));
export async function middleware(req: NextRequest) {
const evaluator = await evaluatorPromise;
const { userId } = await auth();
const result = await evaluator.evaluateAsync("CanAccessBeta", {
userId,
path: req.nextUrl.pathname,
});
if (!result.outputs.allowed) {
const url = req.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = { matcher: "/beta/:path*" };auth() is a stand-in for whatever you use to resolve the current
user (NextAuth, Clerk, your own session helper). Next.js middleware
runs on the Edge runtime by default. Opt into
Node.js with runtime: "nodejs" in config if you hit WASM
constraints on Edge.
import express, { NextFunction, Request, Response } from "express";
import { readFile } from "node:fs/promises";
import { createEvaluator } from "@nodora/js";
const program = await readFile("./rules/access.json", "utf8");
const evaluator = await createEvaluator(program);
function requireRule(rule: string) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId } = await auth(req);
const result = await evaluator.evaluateAsync(rule, {
userId,
path: req.path,
});
if (!result.outputs.allowed) {
return res.sendStatus(403);
}
(req as any).ruleResult = result;
next();
} catch (err) {
next(err);
}
};
}
const app = express();
app.get("/beta/dashboard", requireRule("CanAccessBeta"), (req, res) => {
res.json({ hello: "beta" });
});auth(req) is a stand-in for whatever session/JWT helper your app
already uses to identify the caller. Build the evaluator at
startup, not per request. The WASM module
boots on first use; doing that in a hot path adds avoidable latency.
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { createEvaluator } from "@nodora/js";
import program from "./rules/access.json";
const evaluator = await createEvaluator(JSON.stringify(program));
const requireRule = (rule: string) =>
createMiddleware(async (c, next) => {
const { userId } = await auth(c);
const result = await evaluator.evaluateAsync(rule, {
userId,
path: c.req.path,
});
if (!result.outputs.allowed) {
return c.body(null, 403);
}
c.set("ruleResult", result);
await next();
});
const app = new Hono();
app.get("/beta/dashboard", requireRule("CanAccessBeta"), (c) =>
c.json({ hello: "beta" }),
);
export default app;auth(c) is a stand-in for whatever resolves the current user from
the Hono context (a previous middleware, a JWT helper, etc.). On
Workers and edge runtimes, importing NIR as a JSON module bundles
it with the worker — no filesystem reads at runtime.
Managed with @nodora/client
Same middleware shape, with the Nodora platform serving the compiled NIR. The client caches in-process and revalidates in the background, so a published change reaches your runtimes without a redeploy.
// lib/nodora.ts
import { NodoraClient } from "@nodora/client";
export const nodora = new NodoraClient({
apiKey: process.env.NODORA_API_KEY!,
env: process.env.NODE_ENV === "production" ? "production" : "staging",
strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});
export const access = nodora.ruleset("Access");// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { access } from "@/lib/nodora";
export async function middleware(req: NextRequest) {
const { userId } = await auth();
const result = await access.evaluate("CanAccessBeta", {
userId,
path: req.nextUrl.pathname,
});
if (!result.outputs.allowed) {
const url = req.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = { matcher: "/beta/:path*" };import express, { NextFunction, Request, Response } from "express";
import { NodoraClient } from "@nodora/client";
const nodora = new NodoraClient({
apiKey: process.env.NODORA_API_KEY!,
env: "production",
strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});
const access = nodora.ruleset("Access");
function requireRule(rule: string) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const { userId } = await auth(req);
const result = await access.evaluate(rule, {
userId,
path: req.path,
});
if (!result.outputs.allowed) {
return res.sendStatus(403);
}
(req as any).ruleResult = result;
next();
} catch (err) {
next(err);
}
};
}
const app = express();
app.get("/beta/dashboard", requireRule("CanAccessBeta"), (req, res) => {
res.json({ hello: "beta" });
});Flush buffered events on shutdown so they reach the platform:
async function shutdown() {
await nodora.flush();
await nodora.destroy();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { NodoraClient } from "@nodora/client";
const nodora = new NodoraClient({
apiKey: process.env.NODORA_API_KEY!,
env: "production",
strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});
const access = nodora.ruleset("Access");
const requireRule = (rule: string) =>
createMiddleware(async (c, next) => {
const { userId } = await auth(c);
const result = await access.evaluate(rule, {
userId,
path: c.req.path,
});
if (!result.outputs.allowed) {
return c.body(null, 403);
}
c.set("ruleResult", result);
await next();
});
const app = new Hono();
app.get("/beta/dashboard", requireRule("CanAccessBeta"), (c) =>
c.json({ hello: "beta" }),
);
export default app;General notes
- Instantiate once: The evaluator or
NodoraClientshould be module-scoped so it's reused across requests. Re-creating it per request burns startup cost and defeats caching and event batching. - Errors: Wrap evaluation in your framework's error path so a
failed
evaluatecall doesn't break the request loop. - Shutdown: On long-running runtimes, call
await nodora.flush()thenawait nodora.destroy()(managed) orevaluator.destroy()(self-managed) from aSIGTERM/SIGINThook, so buffered events aren't lost and handles don't leak.