Skip to content

Environment Variables and Configuration

Managing environment variables in your project is an essential task, but it can also be challenging. That’s why we have included a complete setup for environment variables in this project. This setup comes with validation and type-checking using the zod library.

All the code related to environment variables is located in the env.js and src/core/env.js files. The env.js read the APP_ENV variable and loads the correct .env file, then defines the zod schema for the environment variables for client and build-time, parses the _env object, and returns the parsed object, or throws errors in case of invalid or missing variables.

To increase security, we are splitting environment variables into two parts:

  • Client Variables: Variables that are safe to be exposed to the client and used in your src folder. These variables are passed to the client side using the extra configuration in the app.config.ts file.
  • Build Time Variables: Variables that we don’t need on the client-side and are only used in the app.config.ts, for example SENTRY_AUTH to upload the source maps to Sentry.

By using this pre-configured setup for environment variables, you can focus on building your project without worrying about managing and validating your environment variables.

This setup is highly inspired by T3 Stack 👌

Adding a new environment variable to the project.

To add a new environment variable to the project, follow these steps:

  1. Add the new environment variable to the correct zod schema inside the env.js file based on this simple rule : If the variable is used in the src folder, add it to the client schema, otherwise add it to the buildTime schema.

This will ensure that the new variable is validated correctly. and make sure we are only sending the correct vars to the client side Here’s an example:

env.js
const client = z.object({
// ...
// add the new environment variable here/ accessible on the client side and build time(app.config.ts)
NEW_ENV_VAR: z.string(),
});
const buildTime = z.object({
// ...
// add the new environment variable here / accessible only on build time(app.config.ts)
NEW_SECRET_ENV: z.string(),
});
  1. Add the new environment variable to the correct env object inside the env.js, _clientEnv for client variables and _buildTimeEnv for build time variables. Here’s an example:
env.js
const _clientEnv = {
// ...
// add the new environment variable here
NEW_ENV_VAR: process.env.NEW_ENV_VAR,
};
const _buildTimeEnv = {
// ...
// add the new environment variable here
NEW_SECRET_ENV: process.env.NEW_SECRET_ENV,
};
  1. Add the new environment variable to your .env files. Make sure to include it in all relevant files (development, staging, and production). Here’s an example:
.env.{APP_ENV}
# ...
# add the new environment variable here
NEW_ENV_VAR=my-new-var

::: note if you are not pushing env files to your repo(recomended), please make sure to check the App releasing process to see how to create the env file on the fly before the prebuild script in the github actions. :::

  1. Make sure to run pnpm prebuild to load the new values.
Terminal window
pnpm prebuild
  1. The new environment variable is now ready to use in your project. You can access it in your code using the Env object, like this:
client.ts
import { Env } from '@env';
import axios from 'axios';
export const client = axios.create({
baseURL: Env.API_URL,
});
  1. Use APP_ENV to load the correct .env file :
Terminal window
APP_ENV=production pnpm start -cc

As mentioned earlier, zod is used to validate environment variables at runtime and build time. If there are any missing or invalid variables, you’ll see an error message with information on what needs to be fixed. Here’s an example error message:

Terminal window
Invalid environment variables: { TEST: [ '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.

How it works

✅ Validate and parse environment variables

If you take a look at the env.js file, you will notice that the file is split into three main parts as shown below:

env.js
/* eslint-env node */
/*
* Env file to load and validate env variables
* Be cautious; this file should not be imported into your source folder.
* We split the env variables into two parts:
* 1. Client variables: These variables are used in the client-side code (src folder).
* 2. Build-time variables: These variables are used in the build process (app.config.ts file).
* Import this file into the `app.config.ts` file to use environment variables during the build process. The client variables can then be passed to the client-side using the extra field in the `app.config.ts` file.
* To access the client environment variables in your `src` folder, you can import them from `@env`. For example: `import Env from '@env'`.
*/
/**
* 1st part: Import packages and Load your env variables
* we use dotenv to load the correct variables from the .env file based on the APP_ENV variable (default is development)
* APP_ENV is passed as an inline variable while executing the command, for example: APP_ENV=staging pnpm build:android
*/
const z = require('zod');
const packageJSON = require('./package.json');
const path = require('path');
const APP_ENV = process.env.APP_ENV ?? 'development';
const envPath = path.resolve(__dirname, `.env.${APP_ENV}`);
require('dotenv').config({
path: envPath,
});
/**
* 2nd part: Define some static variables for the app
* Such as: bundle id, package name, app name.
*
* You can add them to the .env file but we think it's better to keep them here as as we use prefix to generate this values based on the APP_ENV
* for example: if the APP_ENV is staging, the bundle id will be com.obytes.staging
*/
// TODO: Replace these values with your own
const BUNDLE_ID = 'com.obytes'; // ios bundle id
const PACKAGE = 'com.obytes'; // android package name
const NAME = 'ObytesApp'; // app name
const EXPO_ACCOUNT_OWNER = 'obytes'; // expo account owner
const EAS_PROJECT_ID = 'c3e1075b-6fe7-4686-aa49-35b46a229044'; // eas project id
const SCHEME = 'obytesApp'; // app scheme
/**
* We declare a function withEnvSuffix that will add a suffix to the variable name based on the APP_ENV
* Add a suffix to variable env based on APP_ENV
* @param {string} name
* @returns {string}
*/
const withEnvSuffix = (name) => {
return APP_ENV === 'production' ? name : `${name}.${APP_ENV}`;
};
/**
* 2nd part: Define your env variables schema
* we use zod to define our env variables schema
*
* we split the env variables into two parts:
* 1. client: These variables are used in the client-side code (`src` folder).
* 2. buildTime: These variables are used in the build process (app.config.ts file). You can think of them as server-side variables.
*
* Main rules:
* 1. If you need your variable on the client-side, you should add it to the client schema; otherwise, you should add it to the buildTime schema.
* 2. Whenever you want to add a new variable, you should add it to the correct schema based on the previous rule, then you should add it to the corresponding object (_clientEnv or _buildTimeEnv).
*
* Note: `z.string()` means that the variable exists and can be an empty string, but not `undefined`.
* If you want to make the variable required, you should use `z.string().min(1)` instead.
* Read more about zod here: https://zod.dev/?id=strings
*
*/
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(),
// ADD YOUR CLIENT ENV VARS HERE
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(),
// ADD YOUR BUILD TIME ENV VARS HERE
SECRET_KEY: z.string(),
});
/**
* @type {Record<keyof z.infer<typeof client> , unknown>}
*/
const _clientEnv = {
APP_ENV,
NAME: NAME,
SCHEME: SCHEME,
BUNDLE_ID: withEnvSuffix(BUNDLE_ID),
PACKAGE: withEnvSuffix(PACKAGE),
VERSION: packageJSON.version,
// ADD YOUR ENV VARS HERE TOO
API_URL: process.env.API_URL,
VAR_NUMBER: Number(process.env.VAR_NUMBER),
VAR_BOOL: process.env.VAR_BOOL === 'true',
};
/**
* @type {Record<keyof z.infer<typeof buildTime> , unknown>}
*/
const _buildTimeEnv = {
EXPO_ACCOUNT_OWNER,
EAS_PROJECT_ID,
// ADD YOUR ENV VARS HERE TOO
SECRET_KEY: process.env.SECRET_KEY,
};
/**
* 3rd part: Merge and Validate your env variables
* We use zod to validate our env variables based on the schema we defined above
* If the validation fails we throw an error and log the error to the console with a detailed message about missed variables
* If the validation passes we export the merged and parsed env variables to be used in the app.config.ts file as well as a ClientEnv object to be used in the client-side code
**/
const _env = {
..._clientEnv,
..._buildTimeEnv,
};
const merged = buildTime.merge(client);
const parsed = merged.safeParse(_env);
if (parsed.success === false) {
console.error(
'❌ Invalid environment variables:',
parsed.error.flatten().fieldErrors,
`\n❌ Missing variables in .env.${APP_ENV} file, Make sure all required variables are defined in the .env.${APP_ENV} file.`,
`\n💡 Tip: If you recently updated the .env.${APP_ENV} file and the error still persists, try restarting the server with the -c flag to clear the cache.`
);
throw new Error(
'Invalid environment variables, Check terminal for more details '
);
}
const Env = parsed.data;
const ClientEnv = client.parse(_clientEnv);
module.exports = {
Env,
ClientEnv,
withEnvSuffix,
};

In the first part We load the correct .env file based on the APP_ENV variable using dotenv package. If the APP_ENV variable is not defined, we default to development.

we define some static variables for the app such as the app name, bundle Id and package. While these variables can be added to the .env files, we recommend keeping them in the env.js file as they are not meant to change. To handle different app variants, you can add suffixes to these variables using the withEnvSuffix function.

In the second part, we define the zod schema for the environment variables.

We split the environment variables into two parts:

  • Client Variables: Variables that are safe to be exposed to the client and used in the src folder.

  • Build Time Variables: Variables that we don’t need on the client-side and are only used in the app.config.ts, for example, SENTRY_AUTH to upload the source maps to Sentry.

These schemas are used to validate the environment variables. All the environment variables should be added to the correct schema.

We use the z.infer utility to infer the environment variables’ types from the schema and use it to define the _clientEnv and _buildTimeEnv objects’ type. This means that if you add a new environment variable to the schema, you will get a type error if you don’t add it to the correct _clientEnv and _buildTimeEnv object as well, and vice versa.

Finally, in the third part, we merge variables to _env, pare it using the zod schema, and return the parsed object as well as the client environment variable, or throw errors in case of invalid or missing variables.

✅ Use and send environment variables to the client

Now it’s as easy as importing Env , ClientEnv and withEnvSuffix from the ./env.js file and use inside our app.config.ts, and finally sending client env vars to the client side using extra property.

app.config.ts
/* eslint-disable max-lines-per-function */
import type { ConfigContext, ExpoConfig } from '@expo/config';
import { ClientEnv, Env } from './env';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: Env.NAME,
description: `${Env.NAME} Mobile App`,
owner: Env.EXPO_ACCOUNT_OWNER,
scheme: Env.SCHEME,
slug: 'obytesapp',
version: Env.VERSION.toString(),
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
splash: {
image: './assets/splash.png',
resizeMode: 'cover',
backgroundColor: '#2E3C4B',
},
updates: {
fallbackToCacheTimeout: 0,
},
assetBundlePatterns: ['**/*'],
ios: {
supportsTablet: true,
bundleIdentifier: Env.BUNDLE_ID,
},
experiments: {
typedRoutes: true,
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#2E3C4B',
},
package: Env.PACKAGE,
},
web: {
favicon: './assets/favicon.png',
bundler: 'metro',
},
plugins: [
[
'expo-font',
{
fonts: ['./assets/fonts/Inter.ttf'],
},
],
'expo-localization',
'expo-router',
[
'app-icon-badge',
{
enabled: Env.APP_ENV !== 'production',
badges: [
{
text: Env.APP_ENV,
type: 'banner',
color: 'white',
},
{
text: Env.VERSION.toString(),
type: 'ribbon',
color: 'white',
},
],
},
],
],
extra: {
...ClientEnv,
eas: {
projectId: Env.EAS_PROJECT_ID,
},
},
});

✅ Type checking for client environment variables

Here, we added a separate file to export all variables that have already been passed in the extra property to the client side. We added a little bit of magic to make it type-safe and easy to use.

src/core/env.js
/*
* This file should not be modified; use `env.js` in the project root to add your client environment variables.
* If you import `Env` from `@env`, this is the file that will be loaded.
* You can only access the client environment variables here.
* NOTE: We use js file so we can load the client env types
*/
import Constants from 'expo-constants';
/**
* @type {typeof import('../../env.js').ClientEnv}
*/
//@ts-ignore // Don't worry about TypeScript here; we know we're passing the correct environment variables to `extra` in `app.config.ts`.
export const Env = Constants.expoConfig?.extra ?? {};

Now the environment variables are ready to use in your project. You can access them in your code by importing Env from @env and using it like this:

client.ts
import { Env } from '@env';
import axios from 'axios';
export const client = axios.create({
baseURL: Env.API_URL,
});