@workspace/env
Type-safe environment variable validation with Zod
Environment variable validation using t3-env and Zod schemas. Provides runtime validation, TypeScript types, and CI-friendly configuration patterns.
Runtime Validation
Call loadServerEnv()
inside functions, not at module top-level. This ensures environment variables load correctly in serverless and edge environments.
Package Exports
Core Exports
loadServerEnv()
Loads and validates server-side environment variables at runtime.
import { loadServerEnv } from "@workspace/env/server";
export async function handler() {
const env = loadServerEnv();
// Access validated variables
const secret = env.BETTER_AUTH_SECRET;
const apiKey = env.AUTUMN_SECRET_KEY;
}
Available Variables
Heads up
Never call loadServerEnv()
at module top-level. Serverless functions may execute module code in unexpected contexts, causing initialization errors.
Correct Usage
// ✅ Correct - function scoped
export async function GET() {
const { AUTUMN_SECRET_KEY } = loadServerEnv();
const client = new Autumn({ secretKey: AUTUMN_SECRET_KEY });
}
// ❌ Wrong - module level
const { AUTUMN_SECRET_KEY } = loadServerEnv();
export async function GET() {
const client = new Autumn({ secretKey: AUTUMN_SECRET_KEY });
}
nextEnv
Build-safe environment access for Next.js configuration.
import { nextEnv } from "@workspace/env/next";
export default {
env: {
APP_BASE_URL: nextEnv.APP_BASE_URL ?? "http://localhost:3000",
},
};
nextEnv vs loadServerEnv()
// loadServerEnv() - Runtime validation (strict)
{
GOOGLE_CLIENT_SECRET: z.string().min(1) // Required in production
}
// nextEnv - Build-time access (relaxed)
{
GOOGLE_CLIENT_SECRET: z.string().min(1).optional() // Optional for CI builds
}
The nextEnv
schema allows builds to succeed without server secrets, enabling CI/CD pipelines to build applications before deployment.
publicEnv
Client-side environment variables with NEXT_PUBLIC_
prefix.
import { publicEnv } from "@workspace/env/client";
export function DashboardLink() {
return (
<a href={publicEnv.NEXT_PUBLIC_DASHBOARD_BASE_URL}>
Go to Dashboard
</a>
);
}
Available Variables
{
NEXT_PUBLIC_CONVEX_URL: string;
NEXT_PUBLIC_CONVEX_SITE_URL: string;
NEXT_PUBLIC_DASHBOARD_BASE_URL: string;
NEXT_PUBLIC_POSTHOG_KEY?: string;
NEXT_PUBLIC_POSTHOG_HOST?: string;
}
Client Exposure
All NEXT_PUBLIC_
variables are inlined at build time and visible in browser JavaScript. Never use this prefix for secrets.
Validation Patterns
Production vs Development
Environment validation adjusts based on NODE_ENV
:
const isProd = process.env.NODE_ENV === "production";
export const serverEnv = createEnv({
server: {
GOOGLE_CLIENT_SECRET: isProd
? z.string().min(1) // Required in production
: z.string().min(1).default("placeholder-secret"), // Default in dev
},
// ...
});
Development defaults allow rapid prototyping. Production validation enforces security requirements.
URL Validation
URLs undergo format and protocol validation:
const httpsOrLoopbackUrl = z
.string()
.url()
.refine(
(value) => {
if (!isProd) return true; // Any URL in development
if (value.startsWith("https://")) return true;
// Allow http://localhost in production for local builds
const { hostname, protocol } = new URL(value);
const isLoopback = hostname === "localhost" || hostname === "127.0.0.1";
return isLoopback && protocol === "http:";
},
{ message: "URL must use https:// in production" }
);
This prevents accidental HTTP usage in production while allowing local development.
Optional with Defaults
Non-critical variables use optional validation with defaults:
{
NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().url().optional(),
}
Missing analytics configuration disables tracking rather than failing application startup.
CI/CD Integration
Build Without Secrets
Two approaches for CI builds:
1. nextEnv Pattern (Recommended)
import { nextEnv } from "@workspace/env/next";
// Builds succeed without server secrets
const appUrl = nextEnv.APP_BASE_URL ?? "http://localhost:3000";
2. SKIP_ENV_VALIDATION (Fallback)
# .env.ci
SKIP_ENV_VALIDATION=true
NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud
The skip flag disables all validation. Use nextEnv
when possible for partial validation.
Placeholder URLs
CI-safe placeholder values:
NEXT_PUBLIC_CONVEX_URL=https://placeholder.convex.cloud
NEXT_PUBLIC_CONVEX_SITE_URL=https://placeholder.convex.site
GOOGLE_CLIENT_ID=placeholder-google-client-id
GOOGLE_CLIENT_SECRET=placeholder-google-client-secret
Applications detect placeholders and adjust behavior (e.g., convexClient
returns null
).
Error Handling
Validation Failures
Missing or invalid variables produce detailed errors:
❌ Invalid environment variables:
- GOOGLE_CLIENT_SECRET: Required
- APP_BASE_URL: Invalid url
- NEXT_PUBLIC_CONVEX_URL: Required
Multiple errors appear together, showing all issues at once.
Runtime vs Build-time Errors
// Build-time error (nextEnv)
import { nextEnv } from "@workspace/env/next";
// Validates on import - fails build if invalid
// Runtime error (loadServerEnv)
const env = loadServerEnv();
// Validates on function call - fails at runtime if invalid
Use nextEnv
for build configuration. Use loadServerEnv()
for request handlers.
Usage Patterns
API Route Configuration
import { loadServerEnv } from "@workspace/env/server";
import { Autumn } from "autumn-js";
export async function POST(request: Request) {
const { AUTUMN_SECRET_KEY } = loadServerEnv();
const client = new Autumn({ secretKey: AUTUMN_SECRET_KEY });
// Use client...
}
Server Component Usage
import { loadServerEnv } from "@workspace/env/server";
export default async function DashboardPage() {
const { DASHBOARD_BASE_URL } = loadServerEnv();
return (
<div>
<p>Dashboard URL: {DASHBOARD_BASE_URL}</p>
</div>
);
}
Middleware Configuration
import { loadServerEnv } from "@workspace/env/server";
export function middleware(request: NextRequest) {
const { DASHBOARD_BASE_URL } = loadServerEnv();
if (request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/", DASHBOARD_BASE_URL));
}
}
Testing
Test Environment Setup
# Required for tests
NEXT_PUBLIC_CONVEX_URL=https://test.convex.cloud
GOOGLE_CLIENT_ID=test-client-id
GOOGLE_CLIENT_SECRET=test-client-secret
AUTUMN_SECRET_KEY=am_sk_test_placeholder
# Skip validation if using mocks
SKIP_ENV_VALIDATION=true
Mocking Environment Variables
import { vi } from "vitest";
vi.mock("@workspace/env/server", () => ({
loadServerEnv: vi.fn(() => ({
GOOGLE_CLIENT_ID: "test-client-id",
GOOGLE_CLIENT_SECRET: "test-secret",
AUTUMN_SECRET_KEY: "am_sk_test_key",
APP_BASE_URL: "http://localhost:3000",
DASHBOARD_BASE_URL: "http://localhost:3001",
})),
}));
Testing Validation Logic
import { expect, test } from "vitest";
import { loadServerEnv } from "@workspace/env/server";
test("requires GOOGLE_CLIENT_SECRET in production", () => {
process.env.NODE_ENV = "production";
delete process.env.GOOGLE_CLIENT_SECRET;
expect(() => loadServerEnv()).toThrow("GOOGLE_CLIENT_SECRET");
});
test("provides default in development", () => {
process.env.NODE_ENV = "development";
delete process.env.GOOGLE_CLIENT_SECRET;
const env = loadServerEnv();
expect(env.GOOGLE_CLIENT_SECRET).toBe("placeholder-google-client-secret");
});
Security Considerations
Server-Only Variables
Server variables never reach the client:
// ❌ TypeScript error - server env not accessible in client
"use client";
import { loadServerEnv } from "@workspace/env/server";
const env = loadServerEnv(); // Build error
Package configuration prevents server environment imports in client code.
Secret Validation
Secrets require minimum lengths:
{
BETTER_AUTH_SECRET: z.string().min(32),
AUTUMN_SECRET_KEY: z.string().min(20),
}
This enforces security baselines for cryptographic operations.
Environment Leakage Prevention
Error messages never expose secret values:
// Error message
"GOOGLE_CLIENT_SECRET: Required"
// NOT
"GOOGLE_CLIENT_SECRET: Expected 'sk_live_abc123...', received undefined"
Validation errors indicate missing/invalid variables without revealing actual values.
Architecture
Validation Strategy
Environment validation occurs at three points:
Build Time - nextEnv
validates during Next.js build
Module Load - publicEnv
validates when importing client package
Runtime - loadServerEnv()
validates on function execution
This layered approach catches issues early while supporting CI/CD workflows.
Type Safety
Validated environment objects provide full TypeScript inference:
const env = loadServerEnv();
// TypeScript knows these properties exist
env.GOOGLE_CLIENT_ID; // string
env.HSTS_PRELOAD; // "0" | "1" | undefined
// Autocomplete works
env.AUTUMN_ // Suggests AUTUMN_SECRET_KEY
No manual type annotations required after validation.
Common Patterns
Configuration Objects
Group related variables for subsystems:
// packages/env/src/server.ts
export function getAuthConfig() {
const env = loadServerEnv();
return {
googleClientId: env.GOOGLE_CLIENT_ID,
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
betterAuthSecret: env.BETTER_AUTH_SECRET,
};
}
URL Construction
Helper functions for consistent URL building:
// packages/env/src/url.ts
export function getDashboardUrl(path: string = "/") {
const { DASHBOARD_BASE_URL } = loadServerEnv();
return new URL(path, DASHBOARD_BASE_URL).toString();
}
// Usage
const loginUrl = getDashboardUrl("/sign-in");
AI-Assisted Configuration
AI assistants understand environment patterns:
Natural Language to Config
Describe needed configuration like "add Stripe integration" and AI generates appropriate schema definitions with validation rules automatically.
// Prompt: "Add Stripe configuration"
{
STRIPE_SECRET_KEY: isProd
? z.string().min(1).startsWith("sk_live_")
: z.string().min(1).default("sk_test_placeholder"),
STRIPE_WEBHOOK_SECRET: isProd
? z.string().min(1)
: z.string().min(1).default("whsec_test_placeholder"),
}
The AI applies appropriate validation, defaults, and environment-specific constraints.
Migration Guide
Adding New Variables
Add schema definition to packages/env/src/server.ts
or client.ts
Add to runtimeEnv
object mapping process.env
values
Update .env.example
with documentation
Add to deployment platform environment configuration
Removing Variables
Search codebase for usage (loadServerEnv()
or publicEnv
)
Remove from schema and runtimeEnv
Remove from .env.example
and deployment platforms
Document removal in migration notes if breaking change