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"
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>
)
}
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
:
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
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 }
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,
},
})
}
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:
The problem is that we are exposing some variables to the browser but TypeScript doesn't know about them yet:
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
}
}
}
And we now have code completion for the window.ENV
variables:
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)}`,
}}
/>
)
}
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>
)
}
As you can see, everything is strongly typed:
However, if we run the code above (with the console.log
) we are going to see an error:
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]
}
By using a generic T extends keyof Props
we get code completion when calling 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]
}
Now we throw a friendly error message to our fellow peers in case they mess up with our dear component:
Strongly-typed universal environment variables unlocked, yay!
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
}
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',
]),
}
}
Update the root.tsx
's loader function to use getPublicKeys
:
// app/root.tsx
export function loader() {
return json(getPublicKeys())
}
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 }
}
Done! We now have a single source of truth for our universal environment variables.
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 π€
Top comments (7)
Little late to the party, but you could use
moderndash
instead oflodash
here and get better type safety.moderndash.io/docs/pick
Awesome @cubanx !! Didn't know that package ;)
Thanks for the great post! Was it intentional to re-parse the schema every time you access an env var?
Hey Christopher!
I have now revisited the code and created another post you might like. I incorporated your suggestion and created a little cache:
dev.to/seasonedcc/type-safe-env-va...
Cheers
Hey Christopher! Thanks for the feedback. Yeah it is intentional. It can be improved but haven't felt the need when writing this post =)
Thanks for this!
Thank you for the kindness!!