Skip to content

.env

Dotenv is the de facto standard, but it lacks validation, making it prone to errors. In this template, we use zod to validate the required environment variables in files like next.config.ts. If any required variables are missing or invalid, the application will fail to execute. This approach ensures robustness at runtime.

sample
# Next.js
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Database
DATABASE_USER=local
DATABASE_PASSWORD=1234
DATABASE_DB=database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_SCHEMA=public
# for prisma migration, not used in development, test and production
DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_DB}?schema=${DATABASE_SCHEMA}

# Google OAuth
# https://console.cloud.google.com/apis/credentials
# Set values below
# AUTHORIZED JAVASCRIPT ORIGINS: http://localhost:3000
# AUTHORIZED REDIRECT URIS: http://localhost:3000/api/auth/callback/google
GOOGLE_CLIENT_ID=xxxx
GOOGLE_CLIENT_SECRET=xxxx

# NextAuth.js
NEXTAUTH_URL=${NEXT_PUBLIC_SITE_URL}
# https://next-auth.js.org/configuration/options#secret
# you must generate a new secret
# error: "ikm" must be at least one byte in length'
# $ openssl rand -base64 32
NEXTAUTH_SECRET=TKDdLVjf7cTyTs5oWVpv04senu6fia4RGQbYHRQIR5Q=

# start: otel #
# OpenTelemetry
TRACE_EXPORTER_URL=
# end: otel #

# start: stripe #
# Stripe
STRIPE_SECRET_KEY=xxxx
STRIPE_WEBHOOK_SECRET=xxxx
STRIPE_PRICE_ID=xxxx
# end: stripe #
ts
import { loadEnvConfig } from "@next/env";
import { z } from "zod";

export type Schema = z.infer<typeof schema>;

const schema = z.object({
  NODE_ENV: z
    .union([
      z.literal("development"),
      z.literal("test"),
      z.literal("production"),
    ])
    .default("development"),

  // for client and server
  NEXT_PUBLIC_SITE_URL: z.string().url(),

  // for server
  DATABASE_USER: z.string().min(1),
  DATABASE_PASSWORD: z.string().min(1),
  DATABASE_DB: z.string().min(1),
  DATABASE_HOST: z.string().min(1),
  DATABASE_PORT: z.coerce.number().min(1),
  DATABASE_SCHEMA: z.string().min(1),

  GOOGLE_CLIENT_ID: z.string().min(1),
  GOOGLE_CLIENT_SECRET: z.string().min(1),

  NEXTAUTH_URL: z.string().min(1),
  NEXTAUTH_SECRET: z.string().min(1),

  /* start: otel */
  TRACE_EXPORTER_URL: z.string().url().optional().or(z.literal("")),
  /* end: otel */

  /* start: stripe */
  STRIPE_PRICE_ID: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().min(1),
  STRIPE_WEBHOOK_SECRET: z.string().min(1),
  /* end: stripe */
});

export function config() {
  const { combinedEnv } = loadEnvConfig(process.cwd());
  const res = schema.safeParse(combinedEnv);

  if (res.error) {
    console.error("\x1b[31m%s\x1b[0m", "[Errors] environment variables");
    console.error(JSON.stringify(res.error.errors, null, 2));
    process.exit(1);
  }
}

If you need to add new environment variables, make sure to add them to both .env and .env.test, and update the env.ts with the corresponding zod validation.

Why not use .env.local?

Prisma can read the .env file, and the DATABASE_URL is a required key for migration. While Next.js prioritizes .env.local, splitting the dotenv files can be inefficient. Therefore, to maintain consistency with Prisma, this template uses .env.