StarterApp Docs
Packages

@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.

Function-scoped usage
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

Runtime loading pattern
// ✅ 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.

next.config.ts usage
import { nextEnv } from "@workspace/env/next";

export default {
  env: {
    APP_BASE_URL: nextEnv.APP_BASE_URL ?? "http://localhost:3000",
  },
};

nextEnv vs loadServerEnv()

Schema differences
// 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.

Client usage
import { publicEnv } from "@workspace/env/client";

export function DashboardLink() {
  return (
    <a href={publicEnv.NEXT_PUBLIC_DASHBOARD_BASE_URL}>
      Go to Dashboard
    </a>
  );
}

Available Variables

Client environment 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:

Environment-specific validation
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:

HTTPS enforcement in production
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:

Analytics example
{
  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)

Using nextEnv in build scripts
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)

CI environment
# .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:

Development placeholders
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:

Example error output
❌ 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

Error timing
// 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

app/api/route.ts
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

app/dashboard/page.tsx
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

middleware.ts
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

.env.test
# 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

Test setup
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

Validation tests
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:

Type safety prevents exposure
// ❌ 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:

Secret constraints
{
  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:

Safe error handling
// 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:

Type 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:

Grouped exports
// 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:

URL utilities
// 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.

AI-generated schema
// 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