nx-safe-suite
A suite of independently installable, type-safe building blocks for production-grade Next.js backends. No framework on top of a framework. Just five focused packages that compose well together.
The five packages
How they fit together
Each package is useful on its own. Together they cover the standard concerns of a production Next.js backend in a coherent, composable way.
// 1. Validate environment at startup
export const env = createEnv({ server: { DATABASE_URL: z.string().url() }, runtimeEnv: process.env });
// 2. Protect your route
export const POST = guard.withGuard(
{ roles: ['admin'], body: CreateProjectSchema },
async (req, { user, body }) => {
const project = await getOrCreate(body); // 3. Cached
await audit.log({ action: 'project.create', actor: { id: user.id } }); // 4. Audited
return created(project, { links: { self: `/api/projects/${project.id}` } }); // 5. RFC 9457
}
);
TypeScript
Installation
Each package is published independently under the @nx-safe-suite scope. Install only what you need.
Individual packages
pnpm add @nx-safe-suite/env zod
pnpm add @nx-safe-suite/api-response
pnpm add @nx-safe-suite/route-guard zod
pnpm add @nx-safe-suite/server-cache
pnpm add @nx-safe-suite/audit-log
Shell
Requirements
| Requirement | Version |
|---|---|
| Node.js | 18.18 or later |
| Next.js | 13.4 or later (App Router) |
| TypeScript | 5.0 or later |
| zod (peer, for env and route-guard) | 3.23 or 4.x |
| jose (peer, optional, for JWT auth) | 4.0 or later |
Enabling GitHub Pages
This site is served from the docs/ folder on the main branch. To enable it on your fork, go to Settings → Pages → Source and select the docs folder.
Environment validation
Validates process.env against Zod schemas at startup. If a variable is missing or invalid, the process exits immediately with a readable report. No more silent crashes in production.
Quick start
import { createEnv } from "@nx-safe-suite/env";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(10),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: process.env,
});
env.ts
env.ts from next.config.js so that a misconfigured environment fails the build before any code is deployed.
API Reference
createEnv(options)
| Option | Type | Default | Description |
|---|---|---|---|
| server | Record<string, ZodType> | {} | Server-only schemas. Never exposed to the browser. |
| client | Record<string, ZodType> | {} | Browser-exposed schemas. Keys must match clientPrefix. |
| shared | Record<string, ZodType> | {} | Valid in both contexts. No prefix required. |
| runtimeEnv | Record<string, string | undefined> | required | The values to validate. Pass process.env. |
| clientPrefix | string | "NEXT_PUBLIC_" | Expected prefix for all client keys. |
| onValidationError | "exit" | "throw" | "exit" | What happens when validation fails. |
| skipValidation | boolean | false | Bypass validation entirely. Escape hatch for build stages. |
API Reference
| Export | Description |
|---|---|
| createEnv | Main factory function. |
| EnvValidationError | Thrown when onValidationError: "throw". Has a fieldErrors array. |
| EnvFieldError | Type: { key: string; messages: string[] }. |
| CreateEnvOptions | Full options interface. |
Recipes
Fail the build, not just the boot
// next.config.js
import "./env.ts";
export default { /* your config */ };
next.config.js
Skip validation in Docker build stages
export const env = createEnv({
server: { DATABASE_URL: z.string().url() },
runtimeEnv: process.env,
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});
TypeScript
Use throw mode in tests
export const env = createEnv({
runtimeEnv: process.env,
onValidationError: process.env.NODE_ENV === "test" ? "throw" : "exit",
});
TypeScript
API responses
Typed helpers that produce consistent JSON envelopes. Success responses follow the { data, meta, links? } shape. Error responses follow RFC 9457 with a custom code field for machine-readable business errors.
Quick start
import { ok, notFound } from "@nx-safe-suite/api-response";
export async function GET(_req, { params }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) return notFound({ code: "USER_NOT_FOUND" });
return ok(user, { links: { self: `/api/users/${user.id}` } });
}
app/api/users/[id]/route.ts
Success helpers
| Helper | Status | Notes |
|---|---|---|
| ok(data, opts?) | 200 | Standard success response. |
| created(data, opts?) | 201 | Sets Location header when links.self is provided. |
| accepted(data, opts?) | 202 | For async job responses. |
| noContent(opts?) | 204 | Empty body. Use for DELETE. |
Response shape
{
"data": { "id": "123", "name": "Albert" },
"meta": { "timestamp": "2026-07-07T12:00:00.000Z" },
"links": { "self": "/api/users/123" }
}
JSON
Error helpers
| Helper | Status | Special behaviour |
|---|---|---|
| badRequest(opts?) | 400 | |
| unauthorized(opts?) | 401 | Adds WWW-Authenticate: Bearer. |
| forbidden(opts?) | 403 | |
| notFound(opts?) | 404 | |
| conflict(opts?) | 409 | |
| unprocessable(opts?) | 422 | Accepts errors for field-level detail. |
| tooManyRequests(opts?) | 429 | Accepts retryAfter in seconds. |
| internalServerError(opts?) | 500 | Sanitises detail by default. |
| error(status, opts?) | any | Generic builder. |
Error shape (RFC 9457)
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User 123 not found.",
"instance": "/api/users/123",
"code": "USER_NOT_FOUND"
}
JSON
Pagination
Offset pagination
return ok(users, {
pagination: { kind: "offset", page: 2, limit: 20, total: 95 },
links: { self: "/api/users?page=2", prev: "/api/users?page=1", next: "/api/users?page=3" },
});
TypeScript
Cursor pagination
return ok(posts, {
pagination: { kind: "cursor", cursor: "eyJpZCI6IjEyMyJ9", hasNextPage: true },
});
TypeScript
Route guard
Declarative authentication, role-based access control, rate limiting, and schema validation for Next.js API Routes and Server Actions. Configure once, apply per route.
Quick start
import { createGuard } from "@nx-safe-suite/route-guard";
import { z } from "zod";
const guard = createGuard({
jwt: { secret: env.JWT_SECRET },
rateLimit: { max: 100, window: "1m" },
});
export const POST = guard.withGuard(
{ roles: ["admin"], body: z.object({ name: z.string() }) },
async (req, { user, body }) => {
return created({ name: body.name, createdBy: user.id });
},
);
app/api/projects/route.ts
API Routes
withGuard(routeOpts, handler)
| Option | Type | Description |
|---|---|---|
| roles | string[] | (user) => boolean | Required roles. Any match grants access. |
| auth | boolean | Set to false for public routes. |
| rateLimit | RateLimitOptions | false | Route-level override. false disables the global default. |
| body | ZodType | Schema for the request body. |
| query | ZodType | Schema for the query string. |
| params | ZodType | Schema for the route params. |
Server Actions
const { withAction } = createGuard({ jwt: { secret: env.JWT_SECRET } });
export const deleteUser = withAction(
{ roles: ["admin"] },
async ({ user, input }) => {
await db.user.delete({ where: { id: input.userId } });
},
);
actions/users.ts
withAction throws a GuardError when auth or RBAC fails, because Server Actions cannot return a Response.
RBAC
// Any role match grants access
guard.withGuard({ roles: ["admin", "editor"] }, handler);
// Dynamic function check
guard.withGuard(
{ roles: (user) => user.organizationId === "org-123" },
handler,
);
// Async check for DB lookups
guard.withGuard(
{ roles: async (user) => { const p = await getPermission(user.id); return p.canWrite; } },
handler,
);
TypeScript
Rate limiting
Built-in LRU in-memory store with a pluggable interface. Window accepts a number (milliseconds) or a string: "500ms", "30s", "1m", "2h", "1d".
const guard = createGuard({
rateLimit: {
max: 60,
window: "1m",
keyFrom: async (req) => (await getSession(req))?.user?.id ?? "anon",
},
});
TypeScript
Server cache
Multi-tier caching for Next.js server code. Memory and Redis layers, TTL, tag-based invalidation, stale-while-revalidate, and stampede protection built in.
Quick start
import { createCache, MemoryStore, RedisStore } from "@nx-safe-suite/server-cache";
const cache = createCache({
layers: [new MemoryStore(200), new RedisStore(new Redis(env.REDIS_URL))],
defaultTtl: 3600,
});
export const getUser = cache(
async (id: string) => db.user.findUnique({ where: { id } }),
{ tags: (id) => [`user:${id}`], ttl: 300 },
);
await getUser.invalidateTag(`user:${id}`);
TypeScript
Cache layers
Layers are checked in order. On a hit, faster layers are back-filled automatically.
// L1: in-memory LRU (max 500 entries)
new MemoryStore(500)
// L2: Redis (ioredis or @upstash/redis)
new RedisStore(new Redis(env.REDIS_URL))
// Custom layer
class MyStore implements CacheStore { /* ... */ }
TypeScript
Tag invalidation
const getPosts = cache(
async (authorId: string) => db.post.findMany({ where: { authorId } }),
{ tags: (authorId) => [`author:${authorId}`, "posts"] },
);
await getPosts.invalidateTag("posts");
await getPosts.invalidateTag(`author:${authorId}`);
TypeScript
Stale-while-revalidate
Return a cached value immediately while refreshing it in the background.
const getDashboard = cache(
async (orgId: string) => buildDashboard(orgId),
{ ttl: 3600, revalidate: 300 },
);
TypeScript
Audit logging
Structured, GDPR-friendly audit logging. Log who did what, when, and to which resource. Sensitive fields are masked before any transport receives the entry.
Quick start
import { createAuditLog, ConsoleTransport, PrismaTransport } from "@nx-safe-suite/audit-log";
export const audit = createAuditLog({
serviceName: "my-saas",
sensitiveFields: ["password", "ssn", "creditCard"],
transports: [
new ConsoleTransport({ pretty: true }),
new PrismaTransport({ model: db.auditLog }),
],
});
await audit.log({
action: "user.delete",
actor: { id: session.user.id },
resource: { type: "user", id: targetUserId },
status: "success",
});
TypeScript
Transports
ConsoleTransport
new ConsoleTransport({ stream: "stdout", pretty: false })
TypeScript
HttpTransport
new HttpTransport({
url: "https://ingest.example.com/audit",
headers: { Authorization: `Bearer ${env.AUDIT_TOKEN}` },
maxAttempts: 3,
retryDelayMs: 500,
timeoutMs: 5000,
})
TypeScript
PrismaTransport
new PrismaTransport({
model: db.auditLog,
mapEntry: (entry) => ({
action: entry.action,
actorId: entry.actor?.id ?? null,
resourceType: entry.resource?.type,
occurredAt: new Date(entry.timestamp!),
}),
})
TypeScript
Custom transport
import type { AuditTransport, AuditEntry } from "@nx-safe-suite/audit-log";
class AxiomTransport implements AuditTransport {
async send(entry: AuditEntry): Promise<void> {
await axiom.ingest("audit-logs", [entry]);
}
}
TypeScript
PII masking
Fields listed in sensitiveFields are replaced by "[REDACTED]" in actor and payload before any transport receives the entry. The check is case-insensitive and applies recursively to nested objects and arrays.
await audit.log({
action: "user.register",
payload: { email: "albert@example.com", password: "hunter2", name: "Albert" },
});
// Transport receives: { email: "[REDACTED]", password: "[REDACTED]", name: "Albert" }
TypeScript
Changelog
All notable changes to the packages in this monorepo.
0.1.0 — July 2026
@nx-safe-suite/env
Initial release. createEnv with server/client/shared schemas, configurable failure strategy, client prefix enforcement, and skipValidation escape hatch. 11 unit tests.
@nx-safe-suite/api-response
Initial release. RFC 9457 error bodies, HATEOAS success envelopes, offset and cursor pagination, and an ApiResponse namespace export. 27 unit tests.
@nx-safe-suite/route-guard
Initial release. createGuard with JWT auth, custom user resolver, RBAC (array and function), LRU rate limiting with pluggable store, Zod schema validation for body/query/params, and Server Action support via withAction. 31 unit tests.
@nx-safe-suite/server-cache
Initial release. createCache with MemoryStore (L1) and RedisStore (L2), TTL, tag-based invalidation, stale-while-revalidate, and stampede protection via Promise deduplication. 22 unit tests.
@nx-safe-suite/audit-log
Initial release. createAuditLog with ConsoleTransport, HttpTransport (retry, timeout), and PrismaTransport (custom mapEntry). Recursive PII masking, silent mode, and parallel transport dispatch. 24 unit tests.