Caracal Starter
Start Here

Environment Variables and Configuration

Complete guide to managing environment variables with Zod validation, type-checking, and multi-environment support in Caracal starter.

Managing environment variables in React Native projects is essential but challenging. The Caracal starter includes a production-ready setup with Zod validation, TypeScript type-checking, and multi-environment support (development, staging, production).

Overview

The environment variable system provides:

  • Type-safe variables with automatic TypeScript inference
  • Runtime validation using Zod schemas
  • Multi-environment support (dev/staging/prod)
  • Security by splitting client and build-time variables
  • Clear error messages when variables are missing or invalid

Key Files:

  • env.js - Root file that loads and validates all environment variables
  • src/lib/env.js - Client-side re-export for use in app code
  • .env.{APP_ENV} - Environment-specific configuration files

This setup is inspired by T3 Stack's excellent environment variable system.


How It Works

Architecture Overview

┌─────────────────────────────────────────────────────────┐
│ .env.{APP_ENV} files                                    │
│ ├── .env.development                                    │
│ ├── .env.staging                                        │
│ └── .env.production                                     │
└──────────────────┬──────────────────────────────────────┘
                   │ Loaded by dotenv

┌─────────────────────────────────────────────────────────┐
│ env.js (Root)                                           │
│ ├── Defines Zod schemas (client + buildTime)           │
│ ├── Validates all variables                            │
│ └── Exports: Env, ClientEnv, withEnvSuffix             │
└──────────────────┬──────────────────────────────────────┘

        ┌──────────┴──────────┐
        │                     │
        ▼                     ▼
┌────────────────┐    ┌──────────────────┐
│ app.config.ts  │    │ src/lib/env.js   │
│ (Build time)   │    │ (Client runtime) │
│ Uses: Env      │    │ Re-exports:      │
│       ClientEnv│    │ ClientEnv        │
└────────────────┘    └─────────┬────────┘


                        ┌───────────────┐
                        │ Your App Code │
                        │ import @env   │
                        └───────────────┘

Variable Types

1. Client Variables (accessible in src/ folder):

  • Exposed to the client-side app
  • Used in your React components and logic
  • Passed via app.config.tsextra field
  • Import from @env in your code

2. Build-Time Variables (only in app.config.ts):

  • Used during the build process
  • Never exposed to the client
  • For sensitive data like API keys for build tools
  • Examples: Sentry auth tokens, EAS project secrets

Quick Start

Accessing Environment Variables

In your app code (anywhere in src/):

import { Env } from '@env';

// Access variables
const apiUrl = Env.API_URL;
const appName = Env.NAME;
const version = Env.VERSION;

Switching Environments

Use the APP_ENV variable to load different configurations:

# Development (default)
pnpm start

# Staging
APP_ENV=staging pnpm start

# Production
APP_ENV=production pnpm start

# Clear cache when switching
pnpm start -c

File Structure

Root env.js File

The root env.js has three main parts:

Part 1: Load Environment File

const APP_ENV = process.env.APP_ENV ?? 'development';
const envPath = path.resolve(__dirname, `.env.${APP_ENV}`);

require('dotenv').config({
  path: envPath,
});
  • Reads APP_ENV variable (defaults to development)
  • Loads corresponding .env.{APP_ENV} file
  • Uses dotenv package to parse variables

Part 2: Define Zod Schemas

const client = z.object({
  APP_ENV: z.enum(['development', 'staging', 'production']),
  NAME: z.string(),
  SCHEME: z.string(),
  BUNDLE_ID: z.string(),
  PACKAGE: z.string(),
  VERSION: z.string(),
  API_URL: z.string(),
  VAR_NUMBER: z.number(),
  VAR_BOOL: z.boolean(),
});

const buildTime = z.object({
  EXPO_ACCOUNT_OWNER: z.string(),
  EAS_PROJECT_ID: z.string(),
  SECRET_KEY: z.string(),
});

Client Schema:

  • Variables used in your app code
  • Validated as specific types (string, number, boolean, enum)
  • Automatically inferred to TypeScript types

Build-Time Schema:

  • Variables only used in app.config.ts
  • Sensitive data never exposed to client
  • For build tools and CI/CD

Part 3: Create Environment Objects

const _clientEnv = {
  APP_ENV,
  NAME: NAME,
  SCHEME: SCHEME,
  BUNDLE_ID: withEnvSuffix(BUNDLE_ID),
  PACKAGE: withEnvSuffix(PACKAGE),
  VERSION: packageJSON.version,
  API_URL: process.env.API_URL,
  VAR_NUMBER: Number(process.env.VAR_NUMBER),
  VAR_BOOL: process.env.VAR_BOOL === 'true',
};

const _buildTimeEnv = {
  EXPO_ACCOUNT_OWNER,
  EAS_PROJECT_ID,
  SECRET_KEY: process.env.SECRET_KEY,
};

Key points:

  • withEnvSuffix() adds environment suffix to bundle ID/package
  • Type conversions: Number() for numbers, === 'true' for booleans
  • Static variables (NAME, BUNDLE_ID) defined in env.js directly

Static Variables

Some variables are defined directly in env.js rather than .env files:

const BUNDLE_ID = 'com.caracal';
const PACKAGE = 'com.caracal';
const NAME = 'CaracalApp';
const EXPO_ACCOUNT_OWNER = 'leanhtuan1994';
const EAS_PROJECT_ID = 'c3e1075b-6fe7-4686-aa49-35b46a229044';
const SCHEME = 'caracalApp';

Why static?

  • These rarely change and are tied to the app's identity
  • Used by withEnvSuffix() to create environment-specific identifiers

Environment-Specific Identifiers:

The withEnvSuffix() function appends environment to bundle IDs:

const withEnvSuffix = (name) => {
  return APP_ENV === 'production' ? name : `${name}.${APP_ENV}`;
};

// Results:
// Development: com.caracal.development
// Staging:     com.caracal.staging
// Production:  com.caracal (no suffix)

Benefit: Install dev, staging, and production builds side-by-side on the same device.


Adding a New Environment Variable

Follow these steps to add a new variable:

Add to Zod Schema

Choose the correct schema based on usage:

For client-side variables (used in src/ folder):

// env.js
const client = z.object({
  // ... existing variables
  NEW_API_KEY: z.string().min(1),
  FEATURE_FLAG: z.boolean(),
  MAX_RETRIES: z.number(),
});

For build-time only variables (used in app.config.ts):

// env.js
const buildTime = z.object({
  // ... existing variables
  SENTRY_AUTH_TOKEN: z.string().min(1),
  BUILD_NUMBER: z.number().optional(),
});

Zod Type Reference:

z.string()              // Any string (can be empty)
z.string().min(1)       // Required non-empty string
z.string().email()      // Email format validation
z.string().url()        // URL format validation
z.number()              // Numeric value
z.boolean()             // Boolean value
z.enum(['a', 'b', 'c']) // One of specific values
z.string().optional()   // Optional string

Add to Environment Object

For client variables:

// env.js
const _clientEnv = {
  // ... existing variables
  NEW_API_KEY: process.env.NEW_API_KEY,
  FEATURE_FLAG: process.env.FEATURE_FLAG === 'true',
  MAX_RETRIES: Number(process.env.MAX_RETRIES),
};

Type Conversion Examples:

// Strings - no conversion needed
MY_STRING: process.env.MY_STRING,

// Numbers - use Number() constructor
MY_NUMBER: Number(process.env.MY_NUMBER),

// Booleans - compare to 'true' string
MY_BOOL: process.env.MY_BOOL === 'true',

// Optional values - check existence
MY_OPTIONAL: process.env.MY_OPTIONAL || undefined,

All environment variables are strings by default. You must convert them to the correct type when reading from process.env.

Add to .env Files

Add the variable to all environment files:

# .env.development
NEW_API_KEY=dev-api-key-12345
FEATURE_FLAG=true
MAX_RETRIES=3
# .env.staging
NEW_API_KEY=staging-api-key-67890
FEATURE_FLAG=true
MAX_RETRIES=5
# .env.production
NEW_API_KEY=prod-api-key-abcdef
FEATURE_FLAG=false
MAX_RETRIES=10

If you don't commit .env files to your repo (recommended for security), make sure to configure them in your CI/CD pipeline. See App Releasing Process for GitHub Actions setup.

Rebuild Native Code

# Clear cache and rebuild
pnpm prebuild

# Or rebuild for specific platform
pnpm prebuild:staging

Use in Your Code

Client variables:

import { Env } from '@env';

export function MyComponent() {
  const apiKey = Env.NEW_API_KEY;
  const isFeatureEnabled = Env.FEATURE_FLAG;
  const retries = Env.MAX_RETRIES;

  return (
    <View>
      <Text>API Key: {apiKey}</Text>
      <Text>Feature: {isFeatureEnabled ? 'ON' : 'OFF'}</Text>
      <Text>Max Retries: {retries}</Text>
    </View>
  );
}

Build-time variables (only in app.config.ts):

// app.config.ts
import { Env } from './env';

export default {
  // ...
  hooks: {
    postPublish: [
      {
        file: 'sentry-expo/upload-sourcemaps',
        config: {
          authToken: Env.SENTRY_AUTH_TOKEN,
        },
      },
    ],
  },
};

TypeScript Integration

Automatic Type Inference

Zod automatically infers TypeScript types from schemas:

type ClientEnv = {
  APP_ENV: 'development' | 'staging' | 'production';
  NAME: string;
  SCHEME: string;
  BUNDLE_ID: string;
  PACKAGE: string;
  VERSION: string;
  API_URL: string;
  VAR_NUMBER: number;
  VAR_BOOL: boolean;
};

Type-Safe Access

import { Env } from '@env';

const url: string = Env.API_URL;        // ✅ Valid
const num: number = Env.VAR_NUMBER;     // ✅ Valid
const bool: boolean = Env.VAR_BOOL;     // ✅ Valid
const invalid = Env.DOES_NOT_EXIST;     // ❌ Type error

Type Checking Enforcement

If you add a variable to the schema but forget to add it to the env object, TypeScript will show an error:

/**
 * @type {Record<keyof z.infer<typeof client>, unknown>}
 */
const _clientEnv = {
  // Must include all keys from schema
};

Validation and Error Handling

Runtime Validation

When the app starts, Zod validates all environment variables:

if (parsed.success === false) {
  console.error(
    '❌ Invalid environment variables:',
    parsed.error.flatten().fieldErrors,
    `\n❌ Missing variables in .env.${APP_ENV} file...`
  );
  throw new Error('Invalid environment variables...');
}

Example Error Messages

Missing variable:

 Invalid environment variables: { API_URL: [ 'Required' ] }
 Missing variables in .env.development file, Make sure all required variables are defined in the .env.development file.
💡 Tip: If you recently updated the .env.development file and the error still persists, try restarting the server with the -c flag to clear the cache.

Wrong type:

 Invalid environment variables: { VAR_NUMBER: [ 'Expected number, received string' ] }

Usage in app.config.ts

import type { ConfigContext, ExpoConfig } from '@expo/config';
import { ClientEnv, Env } from './env';

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: Env.NAME,
  owner: Env.EXPO_ACCOUNT_OWNER,
  scheme: Env.SCHEME,
  version: Env.VERSION,
  ios: {
    bundleIdentifier: Env.BUNDLE_ID,
  },
  android: {
    package: Env.PACKAGE,
  },
  extra: {
    ...ClientEnv,  // ✅ Pass all client vars to app
  },
});

Key points:

  • Import Env for all variables (client + build-time)
  • Import ClientEnv to pass client variables to the app
  • Spread ClientEnv in extra field to make variables accessible in app code
  • Build-time variables (like EXPO_ACCOUNT_OWNER) are NOT in ClientEnv

Environment File Examples

.env.development

# API Configuration
API_URL=https://dev-api.example.com

# Feature Flags
VAR_BOOL=true

# Configuration
VAR_NUMBER=42

# Build Time Only (not accessible in app)
SECRET_KEY=dev-secret-key-12345

.env.staging

API_URL=https://staging-api.example.com
VAR_BOOL=true
VAR_NUMBER=100
SECRET_KEY=staging-secret-key-67890

.env.production

API_URL=https://api.example.com
VAR_BOOL=false
VAR_NUMBER=500
SECRET_KEY=prod-secret-key-abcdef

Common Patterns

API Client Configuration

// src/api/common/client.tsx
import { Env } from '@env';
import axios from 'axios';

export const client = axios.create({
  baseURL: Env.API_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

Feature Flags

// src/lib/features.ts
import { Env } from '@env';

export const features = {
  enableNewUI: Env.FEATURE_FLAG,
  maxRetries: Env.MAX_RETRIES,
  apiUrl: Env.API_URL,
};

Environment-Specific Behavior

import { Env } from '@env';

export function MyComponent() {
  const isDev = Env.APP_ENV === 'development';
  const isProd = Env.APP_ENV === 'production';

  if (isDev) {
    console.log('Debug info:', Env.API_URL);
  }

  return (
    <View>
      {!isProd && (
        <Text className="bg-yellow-200 p-2">
          Environment: {Env.APP_ENV}
        </Text>
      )}
    </View>
  );
}

Best Practices

DO:

  • Use Zod schemas for all environment variables
  • Add variables to ALL .env files (dev, staging, prod)
  • Convert types explicitly (Number(), === 'true')
  • Use client schema for app code variables only
  • Keep secrets in buildTime schema only
  • Commit .env.example but not actual .env files
  • Clear cache when changing env files: pnpm start -c

DON'T:

  • Don't skip type conversion — all env vars are strings
  • Don't use client vars for secrets — use buildTime schema
  • Don't commit .env files with sensitive data
  • Don't access process.env directly in app code — use Env from @env
  • Don't forget to rebuild after adding variables

Troubleshooting

Issue: Variables not updating

# Clear cache and restart
pnpm start -c

# Or rebuild native code
pnpm prebuild

Issue: TypeScript errors after adding variable

Make sure the variable exists in BOTH the schema and the env object:

// 1. Add to schema
const client = z.object({
  NEW_VAR: z.string(),
});

// 2. Add to env object
const _clientEnv = {
  NEW_VAR: process.env.NEW_VAR,
};

Issue: Cannot access variable in app code

Variable is only in buildTime schema — move to client schema if needed in app:

// ❌ Wrong - in buildTime only
const buildTime = z.object({
  API_URL: z.string(),
});

// ✅ Correct - in client schema
const client = z.object({
  API_URL: z.string(),
});

Security Considerations

Never expose these in client schema:

  • API keys for third-party services
  • Database credentials
  • Secret signing keys
  • Private tokens
  • Sentry auth tokens
  • EAS submit credentials

These are safe for client:

  • API URLs (public endpoints)
  • App configuration (feature flags, limits)
  • Public API keys (like Google Maps API key with restrictions)
  • App metadata (name, version, scheme)

Using Secrets in CI/CD

For GitHub Actions:

# .github/workflows/eas-build.yml
- name: Create .env file
  run: |
    cat > .env.production << EOF
    API_URL=${{ secrets.API_URL }}
    VAR_NUMBER=500
    VAR_BOOL=false
    SECRET_KEY=${{ secrets.SECRET_KEY }}
    EOF

See App Releasing Process for complete CI/CD setup.


Resources


Summary

Caracal starter provides a robust environment variable system with:

  • Zod validation - Runtime type checking with clear errors
  • TypeScript inference - Automatic types from schemas
  • Multi-environment - Dev, staging, production configs
  • Security - Client vs build-time variable separation
  • Type safety - No any types, full autocomplete

Quick reference:

  • Add to client schema for app usage
  • Add to buildTime schema for build-only usage
  • Convert types: Number(), === 'true'
  • Import: import { Env } from '@env'
  • Switch environments: APP_ENV=staging pnpm start

On this page