Type-safe environment variables on both client and server with Remix

Gustavo Guichard (Guga) - Nov 14 '22 - - Dev Community

If you are running Remix on Vite, I have an updated post with a simpler, leaner way to get env vars on the client. Check it out!

In this post we are going to use TypeScript to:

  • strongly type our process.env variables
  • expose some public variables from the server to the client, keeping autocomplete all the way

In case you just want to check the final solution, the source code is at the end of the post

As with most Remix tutorials, the contents of this post are not coupled to Remix in any way. If you want to expose env variables from the server to the client this approach might be suitable to your app.

Baseline

Let's say we have some environment variables in our .env file:



# .env
SESSION_SECRET="s3cret"
GOOGLE_MAPS_API_KEY="somekey"
STRIPE_PUBLIC_KEY="should be exposed"
STRIPE_SECRET_KEY="shouldn't be exposed"


Enter fullscreen mode Exit fullscreen mode

We want to expose some of them to the client so we can use some client libraries. All the secret tokens should dwell on the server-side only, but we can safely expose STRIPE_PUBLIC_KEY and GOOGLE_MAPS_API_KEY.

If you don't know how to expose environment variables to the browser with Remix, I recommend you to stop here and take a quick look at the official approach or even Sergio's post prior to reading this post.

By following that approach we end up with this code:



// app/root.tsx
export function loader() {
  return json({
    publicKeys: {
      GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
      STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
    }
  })
}

export default () => {
  const { publicKeys } = useLoaderData<typeof loader>()
  return (
    <html lang="en">
      <body>
        <script
          dangerouslySetInnerHTML={{
            __html: `window.ENV = ${JSON.stringify(publicKeys)}`,
          }}
        />
        {/* ... App ... */}
        <Scripts />
      </body>
    </html>
  )
}


Enter fullscreen mode Exit fullscreen mode

And it works at runtime, yay!

However, on behalf of all the typescript junkies out there, we can't stop here as we shall have type-safety and autocomplete for breakfast for both client and server.

Take a look at types inferred from process.env:
Wide process.env

We can do better than that!

Type-safe process.env

IMO there's no better way to validate data structures on both type level and runtime as Zod. So let's go ahead and install it:



npm install --save zod


Enter fullscreen mode Exit fullscreen mode

Now we can express our .env file in a zod schema and have a function to return the parsed values from that file. Create a new file as follows:



// app/environment.server.ts
import * as z from 'zod'

const environmentSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'production', 'test'])
    .default('development'),
  SESSION_SECRET: z.string().min(1),
  GOOGLE_MAPS_API_KEY: z.string().min(1),
  STRIPE_PUBLIC_KEY: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().min(1),
})

const environment = () => environmentSchema.parse(process.env)

export { environment }


Enter fullscreen mode Exit fullscreen mode

And then update the root.tsx's loader function to use that new helper:



// app/root.tsx
export function loader() {
  return json({
    publicKeys: {
      GOOGLE_MAPS_API_KEY: environment().GOOGLE_MAPS_API_KEY,
      STRIPE_PUBLIC_KEY: environment().STRIPE_PUBLIC_KEY,
    },
  })
}


Enter fullscreen mode Exit fullscreen mode

By doing so, we've achieved some benefits:

  • We now have strongly typed environment variables at the server-side
  • We have a nice documentation of .env file and we can now ditch the .env.sample file
  • Since peers won't run the app without the required env vars, there will be no surprises at runtime

Check out how much narrower our environment variables got:
Type-save process.env

The problem is that we are exposing some variables to the browser but TypeScript doesn't know about them yet:
window.ENV is not type-safe

Let's fix it.

Type-safe window.ENV

To let TypeScript know that our window now has access to those values, we can extend the global Window interface:



declare global {
  interface Window {
    ENV: {
      GOOGLE_MAPS_API_KEY: string
      STRIPE_PUBLIC_KEY: string
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

And we now have code completion for the window.ENV variables:
Type safe window.ENV

The <PublicEnv /> component

Let's create a new React component to keep all the logic related to browser environment variables in a single file.

We can even bring the global declaration here and it will work across the whole app:



// app/ui/public-env.tsx
declare global {
  interface Window {
    ENV: Props
  }
}
type Props = {
  GOOGLE_MAPS_API_KEY: string
  STRIPE_PUBLIC_KEY: string
}
function PublicEnv(props: Props) {
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `window.ENV = ${JSON.stringify(props)}`,
      }}
    />
  )
}


Enter fullscreen mode Exit fullscreen mode

Notice the Props type has the exact values we want to expose to the window.

Now we update our root.tsx file to use that component instead of the <script /> tag:



// app/root.tsx
export default () => {
  const { publicKeys } = useLoaderData<typeof loader>()
  return (
    <html lang="en">
      <body>
        <PublicEnv {...publicKeys} />
      </body>
    </html>
  )
}


Enter fullscreen mode Exit fullscreen mode

As you can see, everything is strongly typed:
Using the PublicEnv

However, if we run the code above (with the console.log) we are going to see an error:
window not defined

As you might've guessed, we can't access window on the server - not talking about you, Deno 🦕 👀 - the same way we can't access process.env in the browser.

Universal environment variables

I'm not sure I like this term but universal has been used quite often to mean a piece of code that can run on both client and server seamlessly.

We need a function that returns us the contents of window.ENV when the runtime does have access to window or the contents of process.env otherwise. Let's create it in the same file as the <PublicEnv /> component:



// app/ui/public-env.tsx
function getPublicEnv<T extends keyof Props>(key: T): Props[T] {
  return typeof window === 'undefined'
    ? environment()[key]
    : window.ENV[key]
}


Enter fullscreen mode Exit fullscreen mode

By using a generic T extends keyof Props we get code completion when calling getPublicEnv:
code completion on getPublicEnv

But if we tell TS that window.ENV is available without actually checking it we are lying to ourselves. What if someone removes the <PublicEnv /> component from the root.tsx or places the component below the <Scripts /> component?

Let's add that check:



// app/ui/public-env.tsx
function getPublicEnv<T extends keyof Props>(key: T): Props[T] {
  if (typeof window !== 'undefined' && !window.ENV) {
    throw new Error(
      `Missing the <PublicEnv /> component at the root of your app.`,
    )
  }

  return typeof window === 'undefined'
    ? environment()[key]
    : window.ENV[key]
}


Enter fullscreen mode Exit fullscreen mode

Now we throw a friendly error message to our fellow peers in case they mess up with our dear component:
Missing the <PublicEnv /> component

Strongly-typed universal environment variables unlocked, yay!
Universal env vars

Final touches

There's still one thing that bothers me. We must keep track of the exposed env vars in two places: the loader function of root.tsx and the Props type of public-env.tsx. Whenever we change one of them we have to tweak the other.

Let's take care of that.

We are going to need a function to pick which variables of our environment are going to make it to the browser. Let's create that function:



function typedPick<T extends {}, U extends Array<keyof T>>(obj: T, keys: U) {
  let result = {} as Pick<T, U[number]>
  for (const key of keys) {
    result[key] = obj[key]
  }
  return result
}


Enter fullscreen mode Exit fullscreen mode

Then, we create a function to return only the env vars that are to be exposed. We co-locate it with the environment function so we keep a single file responsible for that subject:



// app/environment.server.ts

function getPublicKeys() {
  return {
    publicKeys: typedPick(environment(), [
      'STRIPE_PUBLIC_KEY',
      'GOOGLE_MAPS_API_KEY',
    ]),
  }
}


Enter fullscreen mode Exit fullscreen mode

Update the root.tsx's loader function to use getPublicKeys:



// app/root.tsx
export function loader() {
  return json(getPublicKeys())
}


Enter fullscreen mode Exit fullscreen mode

And we can now derive the <PublicEnv />'s Props type out of that new function. Let's update that file:



// app/ui/public-env.tsx
type Props = ReturnType<typeof getPublicKeys>['publicKeys']
declare global {
  interface Window { ENV: Props }
}


Enter fullscreen mode Exit fullscreen mode

Done! We now have a single source of truth for our universal environment variables.

Check out the resulting DX:
Final result

We change one single file and let TypeScript have our backs throughout the App.

That's it 🔥

Let me know what you think about this post in the comments below. I'll be happy to get some feedback, help, and suggestions.

Remix is still quite new and we are figuring stuff out on the go, but I can't help but smile whenever I realize that finding out how to do something with Remix is actually finding out how to do something on the web 🤓

Check the repo on github

. . . . . . . . . . . . . . . . . . . . . . . . .