Dev.to · 9 min read

nx-safe-suite: A Deep Dive Into Five Production-Grade Next.js Packages

nx-safe-suite: A Deep Dive Into Five Production-Grade Next.js Packages

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. @nx-safe-suite/env: Validation That Stops the Process The problem process.env is a Record. 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. The solution @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 design decision worth noting 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. @nx-safe-suite/api-response: A Contract Your Frontend Can Rely On The problem 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. The solution 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. @nx-safe-suite/route-guard: Authentication Without the Boilerplate The problem 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. The solution 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. @nx-safe-suite/server-cache: Multi-Tier Caching That Composes The problem 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. The solution 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. @nx-safe-suite/audit-log: Logging That Survives Compliance The problem 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. The solution 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; } Implement those two lines and any destination works: Axiom, Datadog, Loki, a custom HTTP sink. The Numbers 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. A Note on What This Demonstrates 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. Links GitHub: github.com/adeutou/nx-safe-suite Documentation: adeutou.github.io/nx-safe-suite npm: @nx-safe-suite Questions, feedback, or pull requests are welcome.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Startup & VC News