@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
Optional keys
AUTUMN_SECRET_KEY and DISABLE_USERNAME_PASSWORD_AUTH are optional. When billing is disabled the Autumn key is omitted; guard before constructing the Autumn client. The auth toggle defaults to false, enabling credential sign-in unless explicitly set to true.
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; // Optional – null when billing is disabled
}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() {
const href = publicEnv.NEXT_PUBLIC_DASHBOARD_BASE_URL;
return <a href={href}>Go to Dashboard</a>;
}Available Variables
{
NEXT_PUBLIC_CONVEX_URL: string;
NEXT_PUBLIC_CONVEX_SITE_URL: string;
NEXT_PUBLIC_APP_BASE_URL: string;
NEXT_PUBLIC_DASHBOARD_BASE_URL: string;
NEXT_PUBLIC_POSTHOG_KEY?: string;
NEXT_PUBLIC_POSTHOG_HOST?: string;
NEXT_PUBLIC_ORG_MODE: boolean;
NEXT_PUBLIC_GOOGLE_CLIENT_ID?: string;
NEXT_PUBLIC_GOOGLE_OAUTH_ENABLED: boolean;
NEXT_PUBLIC_DISABLE_USERNAME_PASSWORD_AUTH: boolean;
NEXT_PUBLIC_USERNAME_PASSWORD_AUTH_ENABLED: boolean;
}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.cloudThe 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-secretApplications 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: RequiredMultiple 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 invalidUse 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=trueMocking 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 errorPackage 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_KEYNo 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