Type-safe environment variables in Remix
Handle environment variables in Remix using Zod for type-safety and validation, and separate server and client variables
November 21, 2024If 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:
- No type safety -
process.env
does not know about the variables you've defined and will not provide any type information - No validation - missing or malformed variables only fail at runtime
- No clear separation between server and client variables
- 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:
- Type-safety - TypeScript knows exactly what environment variables exist and their types
- Runtime validation - Zod ensures all required variables are present and correctly formatted
- Security - Clear separation between server-only and client-safe variables
- 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.