v1.0
GitHub npm

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.

5packages
115unit tests
0mandatory runtime deps
MITlicense

The five packages

@nx-safe-suite/env
Strict environment variable validation with Zod, separate server and client schemas.
@nx-safe-suite/api-response
RFC 9457 error bodies, typed success envelopes, offset and cursor pagination.
@nx-safe-suite/route-guard
Declarative RBAC, JWT auth, rate limiting, and schema validation for API Routes and Server Actions.
@nx-safe-suite/server-cache
Multi-tier cache with memory, Redis, TTL, tag invalidation, SWR, and stampede protection.
@nx-safe-suite/audit-log
Structured audit logging with PII masking, Console, HTTP webhook, and Prisma transports.

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

RequirementVersion
Node.js18.18 or later
Next.js13.4 or later (App Router)
TypeScript5.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.

@nx-safe-suite/env v0.1.0

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
Next.js bundling Import your env.ts from next.config.js so that a misconfigured environment fails the build before any code is deployed.

API Reference

createEnv(options)

OptionTypeDefaultDescription
serverRecord<string, ZodType>{}Server-only schemas. Never exposed to the browser.
clientRecord<string, ZodType>{}Browser-exposed schemas. Keys must match clientPrefix.
sharedRecord<string, ZodType>{}Valid in both contexts. No prefix required.
runtimeEnvRecord<string, string | undefined>requiredThe values to validate. Pass process.env.
clientPrefixstring"NEXT_PUBLIC_"Expected prefix for all client keys.
onValidationError"exit" | "throw""exit"What happens when validation fails.
skipValidationbooleanfalseBypass validation entirely. Escape hatch for build stages.
@nx-safe-suite/env

API Reference

ExportDescription
createEnvMain factory function.
EnvValidationErrorThrown when onValidationError: "throw". Has a fieldErrors array.
EnvFieldErrorType: { key: string; messages: string[] }.
CreateEnvOptionsFull options interface.
@nx-safe-suite/env

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
@nx-safe-suite/api-response v0.1.0

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
@nx-safe-suite/api-response

Success helpers

HelperStatusNotes
ok(data, opts?)200Standard success response.
created(data, opts?)201Sets Location header when links.self is provided.
accepted(data, opts?)202For async job responses.
noContent(opts?)204Empty 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
@nx-safe-suite/api-response

Error helpers

HelperStatusSpecial behaviour
badRequest(opts?)400
unauthorized(opts?)401Adds WWW-Authenticate: Bearer.
forbidden(opts?)403
notFound(opts?)404
conflict(opts?)409
unprocessable(opts?)422Accepts errors for field-level detail.
tooManyRequests(opts?)429Accepts retryAfter in seconds.
internalServerError(opts?)500Sanitises detail by default.
error(status, opts?)anyGeneric 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
@nx-safe-suite/api-response

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
@nx-safe-suite/route-guard v0.1.0

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
@nx-safe-suite/route-guard

API Routes

withGuard(routeOpts, handler)

OptionTypeDescription
rolesstring[] | (user) => booleanRequired roles. Any match grants access.
authbooleanSet to false for public routes.
rateLimitRateLimitOptions | falseRoute-level override. false disables the global default.
bodyZodTypeSchema for the request body.
queryZodTypeSchema for the query string.
paramsZodTypeSchema for the route params.
@nx-safe-suite/route-guard

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
Server Actions and errors withAction throws a GuardError when auth or RBAC fails, because Server Actions cannot return a Response.
@nx-safe-suite/route-guard

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
@nx-safe-suite/route-guard

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
@nx-safe-suite/server-cache v0.1.0

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
@nx-safe-suite/server-cache

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
@nx-safe-suite/server-cache

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
@nx-safe-suite/server-cache

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
Stampede protection When concurrent requests trigger a cache miss on the same key, only one call to the source function is made. All waiters share the same Promise.
@nx-safe-suite/audit-log v0.1.0

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
@nx-safe-suite/audit-log

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
@nx-safe-suite/audit-log

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
Original data is never mutated Masking operates on a shallow clone. Your original objects are left untouched.

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.