Type-safe environment variables in Remix

Handle environment variables in Remix using Zod for type-safety and validation

November 21, 2024

If you're building a Remix app you'll most likely need to handle environment variables at some point. Remix does have built-in support for loading environment variables from a system variables and .env files, but there's more we can do to make them type-safe, validate them at runtime, and handle them securely between server and client.

Default behaviour

Create a .env file in your project root with your environment variables:

# .env NODE_ENV=development APP_NAME=My App PUBLIC_SENTRY_DSN=https://[email protected]/xxx OPENAI_API_KEY=sk-xxx

Note: Don't forget to add .env to your .gitignore file to prevent it from being committed to your repository. In production, you'll typically set these environment variables through your hosting platform (like Vercel, Fly.io, etc.) rather than using a .env file.

After this, the docs suggest exposing your environment variables to the client by adding them to your window.ENV object:

export async function loader() { return json({ ENV: { PUBLIC_SENTRY_DSN: process.env.PUBLIC_SENTRY_DSN, }, }); } export function Root() { const data = useLoaderData<typeof loader>(); return ( <html lang="en"> <script dangerouslySetInnerHTML={{ __html: `window.ENV = ${JSON.stringify( data.ENV )}`, }} /> </html> ); }

This approach works, but has a few issues:

  1. No type safety - process.env does not know about the variables you've defined and will not provide any type information
  2. No validation - missing or malformed variables only fail at runtime
  3. No clear separation between server and client variables
  4. No guarantee that sensitive information won't leak to the client

Thankfully, fixing this is very simple. With the help of Zod and Typescript, we can build a robust, type-safe environment variable system that solves all the issues above.

Prerequisites

Install Zod in your project if you haven't already. You could also use Yup, Valibot, or another validation library if you prefer.

1. Define the schema

Define the schema for your environment variables using Zod. This will provide type information and validation for your environment variables. When adding a new environment variable or changing an existing one, you'll only need to update the schema here:

// utils/env.server.ts const envSchema = z.object({ NODE_ENV: z.enum(["production", "development", "test"] as const), APP_NAME: z.string(), // Public client-side vars PUBLIC_SENTRY_DSN: z.string().url(), // Private server-only vars OPENAI_API_KEY: z.string().startsWith('sk-'), });

2. Extend the ProcessEnv interface

We can infer a Typescript type based on our Zod schema above and extend the ProcessEnv interface to include these variables. Doing this will give us IDE autocomplete and type checking when accessing environment variables from process.env in our code:

// utils/env.server.ts export type EnvConfig = z.infer<typeof envSchema>; declare global { namespace NodeJS { interface ProcessEnv extends EnvConfig {} } }

3. Validate the environment variables

At startup, we want to validate that the environment variables are correctly set to avoid any runtime errors due to missing or malformed variables. We can create a function that parses the environment variables and throws an error if they are invalid:

// utils/env.server.ts export function parseEnv(): EnvConfig { const parsed = envSchema.safeParse(process.env); if (!parsed.success) { throw new Error("Invalid environment variables"); } return parsed.data; }

This should be called as early as possible in your app startup, e.g. in entry.server.tsx

// entry.server.tsx import { parseEnv } from "./utils/env.server"; import { createRequestHandler } from "@remix-run/node"; parseEnv(); export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, loadContext: AppLoadContext, // ....

4. Handle client-side variables

To expose environment variables to the client-side code, we will create a function that returns the specific public variables we want. This function will be used in our loaders to pass the variables to the client:

It's crucial to only expose the variables that are safe to be accessed on the client-side. Sensitive information like API keys should remain server-side only.

// utils/env.server.ts export function getPublicClientSideEnvVars() { return { NODE_ENV: process.env.NODE_ENV, APP_NAME: process.env.APP_NAME, PUBLIC_SENTRY_DSN: process.env.PUBLIC_SENTRY_DSN, }; }

Notice how we don't expose the OPENAI_API_KEY variable here as it's meant to be server-side only.

For more type-safety, we can infer another type to represent the ENV object that's available on the window object client-side:

type ENV = ReturnType<typeof getPublicClientSideEnvVars>; declare global { var ENV: ENV; interface Window { ENV: ENV; } }

5. Inject variables into HTML

Create a component to inject the public variables into your HTML. This might look wrong at first but it's the officially recommended way to expose environment variables to the client:

export const PublicEnv = (props: { env: Record<string, string> }) => { return ( <script dangerouslySetInnerHTML={{ __html: `window.ENV = ${JSON.stringify(props)}`, }} /> ); };

Finally, in your root loader and component:

export async function loader({ request }: LoaderFunctionArgs) { return json({ ENV: getPublicClientSideEnvVars(), // ... other loader data }); } export function App() { const data = useLoaderData<typeof loader>(); return ( <html lang="en"> <head> {/* ... other head elements */} </head> <body> <PublicEnv env={data.ENV} /> <Outlet /> {/* ... other body elements */} </body> </html> ); }

6. Using the variables

Now in your client-side code, you can now safely use these variables via window.ENV with added type-safety. Here's an example of using the Sentry DSN in your entry.client.tsx:

// entry.client.tsx import * as Sentry from "@sentry/remix"; import { RemixBrowser } from "@remix-run/react"; import { StrictMode, startTransition, useEffect } from "react"; import { hydrateRoot } from "react-dom/client"; // Initialize services with type-safe client-side environment variables Sentry.init({ enabled: window.ENV.NODE_ENV === "production", dsn: window.ENV.PUBLIC_SENTRY_DSN, environment: window.ENV.NODE_ENV, }); startTransition(() => { hydrateRoot( document, <StrictMode> <RemixBrowser /> </StrictMode>, ); });

For server-side only operations, such as calling OpenAI's API, your variables are available via process.env as normal, but now with type-safety:

export async function action({ request }: ActionFunctionArgs) { // Server-side only, OpenAI key is not exposed to client const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // ...rest of your action }

Conclusion

It might seem like a lot of code up front, but going forward, say when adding a new environment variable, you only need to update the Zod schema and potentially the getPublicClientSideEnvVars function. With this setup, you now have:

  1. Type-safety - TypeScript knows exactly what environment variables exist and their types
  2. Runtime validation - Zod ensures all required variables are present and correctly formatted
  3. Security - Clear separation between server-only and client-safe variables
  4. DX - Better autocomplete and catch typos at compile time

If you're looking for a SaaS starter kit with this environment variable setup and more practices like this, check out Launchway. It comes with authentication, subscriptions, database connections, tons of UI components and more to get your SaaS started.

me
Brian BriscoeCreator of Launchway