How each package works, why it is designed the way it is, and what it replaces.
This is the second article in a two-part series. The first covers the architecture and the reasoning behind the project. This one goes into the implementation of each package.
process.env
is a Record<string, string | undefined>
. Every access is potentially undefined. The standard response to this is to sprinkle ??
and !
operators throughout the codebase, which does not make the code safer. It makes it longer and harder to read while the underlying risk remains.
The real problem is that environment validation happens too late. By the time a missing variable causes an error, the request is already in flight, the user is already waiting, and the stack trace points somewhere unrelated.
@nx-safe-suite/env
validates the entire environment at startup, before the application does anything else. If validation fails, the process exits with a readable report:
❌ Invalid environment variables:
DATABASE_URL
→ Required
API_SECRET
→ String must contain at least 10 character(s)
2 variables failed validation. Fix your .env file and restart.
No stack trace. No cryptic undefined error three function calls deep. Just the problem, described in terms of the configuration, not the code.
The package separates server
and client
schemas. This is not cosmetic. Next.js exposes NEXT_PUBLIC_*
variables to the browser bundle. Any variable not prefixed with NEXT_PUBLIC_
is stripped at build time. If you define a schema for a non-prefixed variable under client
, the package throws synchronously at startup, before the application runs, because you have described a variable as browser-accessible that Next.js will never expose to the browser.
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,
});
The return value is a frozen, fully typed object. env.DATABASE_URL
is a string
, not a string | undefined
. TypeScript knows this. Your editor knows this. The runtime guarantees it.
One additional detail: importing env.ts
from next.config.js
means a misconfigured environment fails the build, not just the boot. The error appears in CI before any code is deployed.
In a codebase with multiple developers and multiple API routes, response shapes drift. One route returns { data: [...] }
. Another returns { results: [...] }
. A third returns the array directly. Error responses are strings, objects, or HTTP status codes alone, depending on who wrote the route and when.
Frontend developers work around this with defensive parsing, optional chaining, and condition checks at every call site. The problem compounds over time.
A small set of typed helpers that always produce the same shapes.
Success responses follow a consistent envelope:
{
"data": { "id": "123", "name": "Albert" },
"meta": { "timestamp": "2026-07-07T12:00:00Z" },
"links": { "self": "/api/users/123" }
}
Error responses follow RFC 9457, the IETF standard for HTTP problem details, extended with a code
field for machine-readable business errors:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User 123 was not found.",
"instance": "/api/users/123",
"code": "USER_NOT_FOUND"
}
The code
field is what makes error handling tractable on the frontend. Instead of parsing status codes or error message strings, client code can switch on a stable, documented identifier.
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) return notFound({
code: "USER_NOT_FOUND",
detail: `User ${params.id} was not found.`,
instance: `/api/users/${params.id}`,
});
return ok(user, { links: { self: `/api/users/${user.id}` } });
}
Pagination, both offset and cursor, is handled as a pagination
option on any success helper. The response meta
block is extended automatically with the computed values (totalPages
, hasNextPage
, hasPrevPage
). No manual calculation at the call site.
A typical Next.js API route that requires authentication, role checking, and input validation looks like this before any business logic runs:
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!session.user.roles.includes("admin")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = CreateProjectSchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error }, { status: 422 });
}
// business logic begins here
}
This is forty lines before any actual work is done. It is also inconsistent across routes, because every developer writes their version of this slightly differently.
createGuard
produces a configured withGuard
wrapper. The cross-cutting concerns, auth, roles, rate limiting, and validation, are declared as configuration. The handler receives a typed context object containing only what it needs.
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().min(1) }),
},
async (req, { user, body }) => {
// user is GuardUser: typed, verified
// body is { name: string }: validated
const project = await db.project.create({
data: { name: body.name, ownerId: user.id },
});
return created(project, { links: { self: `/api/projects/${project.id}` } });
},
);
The RBAC check accepts either a string array, where any match grants access, or an async function, which enables attribute-based access control without a separate library:
roles: async (user) => {
const membership = await db.membership.findFirst({
where: { userId: user.id, organizationId: params.orgId },
});
return membership?.role === "owner";
}
Server Actions are supported via withAction
. The same auth and RBAC pipeline runs without a real Request
object, which Server Actions do not have, and the action receives a typed context containing the resolved user and the input.
The rate limiter is an in-memory LRU store by default. The interface is pluggable: swap it for Upstash or ioredis in a distributed deployment by implementing four methods. The default is good enough for single-instance deployments and zero-dependency prototyping.
Next.js has excellent built-in caching primitives. They are also tightly coupled to the deployment model. The unstable_cache
API and revalidateTag
work well on Vercel. On a self-hosted Node.js server, the behavior is less predictable. On a multi-instance deployment, in-memory caches diverge immediately.
The deeper problem is that most caching implementations choose either simplicity, a single in-memory map, or power, Redis configured manually per use case. There is rarely a middle layer that handles the transition between them.
createCache
returns a function that wraps any async operation with configurable caching behavior. Layers are provided as an ordered array, fastest first.
const cache = createCache({
layers: [
new MemoryStore(200), // L1: in-process LRU
new RedisStore(new Redis(env.REDIS_URL)), // L2: distributed
],
defaultTtl: 3600,
});
export const getUser = cache(
async (id: string) => db.user.findUnique({ where: { id } }),
{ tags: (id) => [`user:${id}`, "users"], ttl: 300 },
);
On a cache miss, the source function is called. The result propagates to all layers. On a cache hit in L2, the result is back-filled to L1 so the next request for the same key is served from memory, without a Redis round-trip.
Tag-based invalidation works across all layers simultaneously. When a user is updated, a single call clears every cache entry associated with that user, regardless of which layer holds it:
await getUser.invalidateTag(`user:${userId}`);
Stampede protection is built in. When dozens of concurrent requests trigger a cache miss for the same key at the same time, a common scenario after a cache expiry under load, only one call to the source function is made. All other callers receive the same Promise and resolve together when the single fetch completes.
Stale-while-revalidate allows returning a cached value immediately, even if it is stale, while refreshing it in the background. The next request gets the fresh value with no latency penalty.
Audit logging is the feature that gets added after the first compliance review, built hastily, and never quite right. The common failure modes are: logs that contain passwords or PII in plaintext, logs that block the main request thread because they write synchronously to a database, and logs in a format that neither humans nor machines can parse reliably.
createAuditLog
returns a logger configured with transports, a service name, and a list of sensitive fields to mask. Every entry sent to audit.log()
is enriched with a timestamp, service name, and default status, then masked, then dispatched to all transports in parallel.
export const audit = createAuditLog({
serviceName: "my-saas",
sensitiveFields: ["password", "ssn", "creditCard", "email"],
silent: true, // transport failures never break the main request path
transports: [
new ConsoleTransport({ stream: "stdout" }), // structured JSON to stdout
new PrismaTransport({ model: db.auditLog }), // persisted to DB
],
});
The PII masking is recursive and case-insensitive. It operates on a shallow clone so the original object is never mutated. A payload containing { email: "albert@example.com", name: "Albert" }
arrives at the transport as { email: "[REDACTED]", name: "Albert" }
.
Three transports ship with the package. ConsoleTransport
writes newline-delimited JSON to stdout or stderr, compatible with any log aggregation pipeline that parses structured stdout. HttpTransport
posts to a webhook endpoint with configurable retry logic and timeout. PrismaTransport
writes to any Prisma model, with a mapEntry
option to transform the entry shape to match your schema exactly.
The transport interface is two lines:
interface AuditTransport {
send(entry: AuditEntry): Promise<void>;
}
Implement those two lines and any destination works: Axiom, Datadog, Loki, a custom HTTP sink.
| Package | Tests | Bundle (ESM) | Peer deps |
|---|---|---|---|
@nx-safe-suite/env |
|||
| 11 | ~3KB | zod | |
@nx-safe-suite/api-response |
|||
| 27 | ~3KB | none | |
@nx-safe-suite/route-guard |
|||
| 31 | ~6KB | zod, jose (optional) | |
@nx-safe-suite/server-cache |
|||
| 22 | ~5KB | none | |
@nx-safe-suite/audit-log |
|||
| 24 | ~5KB | none |
115 tests total. All passing. Strict TypeScript throughout.
If you are reading this as someone evaluating my engineering judgment rather than as someone looking to use these packages, here is what I would point to.
The decision to make transport errors non-fatal by default in audit-log
via silent: true
reflects an understanding of production priorities: a logging failure should never degrade the user experience.
The decision to use Promise deduplication in server-cache
rather than a lock reflects an understanding of the Node.js event loop: locks are unnecessary when you can share the Promise itself.
The decision to validate client variable prefixes eagerly in env
, before schema validation runs, reflects an understanding of where configuration mistakes come from: the schema definition, not the environment values.
These are not clever tricks. They are the kind of decisions that come from having debugged the failure modes they prevent.
Questions, feedback, or pull requests are welcome.