adamhl.dev

Handling environment variables in an Astro project

8 min read

It's more complicated than you think.


note

This post is in the context of an Astro project, but most of what I’m talking about is generally applicable to any TypeScript/JavaScript project.

While building an Astro project, I ran into some issues with environment variables. Astro gives a neat way of defining a type-safe environment variable schema so that environment variables are validated before running/building your site. Astro will throw helpful error messages if there are any environment variables that don’t meet the requirements.

These are accessed via an import like this:

import { SECRET_TOKEN } from "astro:env/server" // SECRET_TOKEN is of type 'string'. No need to check for undefined!

Also, Astro automatically loads environment variables in our .env files. They are also made available in import.meta.env (this is relevant later).

All of this combined gives us what we want:

But, like I mentioned, I ran into some issues.

Importing from astro:env is only possible when in the Astro context.

note

I mention the “Astro context” a lot. This just means that you’re using some astro command (astro dev, astro build, etc.) or running your Astro backend in production.

A good example of where this becomes an issue is when I set up my drizzle client:

db.ts
import { createClient } from "@libsql/client"
import { DB_FILE_NAME } from "astro:env/server"
import { drizzle } from "drizzle-orm/libsql"
const client = createClient({ url: DB_FILE_NAME })
export const db = drizzle({ client })

Here, DB_FILE_NAME is being loaded from my .env file.

This works fine when running the Astro dev server or in production. However, I have a seed.ts script that imports the drizzle client (which imports astro:env/server) to set up the database for development. Unsurprisingly, this script fails because astro:env is not available.

seed.ts
import { db } from "./db" // Throws an error
import { seed } from "drizzle-seed"
await seed(db, { ... })

To be clear about the problem here: seed.ts is being executed directly, so I need some way of accessing environment variables that doesn’t rely on it being imported/executed under Astro.

So, we can use process.env, throw a non-null assertion on it, and call it a day, right? We lose our typed environment variables, but we’ll get to that later.

db.ts
const client = createClient({ url: process.env.DB_FILE_NAME! })

Also, if you’re wondering, remember that we can’t use import.meta.env in this case because it’s not populated with our environment variables outside of the Astro context.

There are two immediate problems with this approach:

We can fix both of these problems by using something like dotenv, which will populate process.env from .env in both cases. (This is actually what drizzle recommends you do in the first place.)

Separately, there are parts of my site that need to be aware of what environment I’m in (e.g. dev vs prod). Astro sets some default environment variables on import.meta.env (but not on process.env) that I can use for this (e.g. import.meta.env.DEV).

Alright, this got complicated. Now I’m accessing environment variables in three ways:

I’m gonna go ahead and say this is not ideal.

Bun solves all of our problems

So, the question becomes, is there a consistent, safe way to access environment variables in all contexts?

Let’s be specific about what we want to achieve. The solution should:

  • Work in all contexts (Astro, scripts, etc.)
  • Validate environment variables before running/building the application
  • Provide one way to access environment variables
  • Automatically load environment variables from .env files
  • Be type-safe (no undefined checks or non-null assertions)

Is this possible? Yes, with some caveats/gotchas.

Let’s break down the problem a little bit more:

  • We know that astro:env is never available outside of the Astro context, so that’s a no go. We want something that works in all contexts.
  • In the Astro context, import.meta.env has all of our environment variables plus the additional properties added by Astro.
  • Knowing that, it would make sense to use import.meta.env by default.
  • Then the problem we’re really trying to solve is: how do we populate import.meta.env outside of the Astro context?
  • If we solve that, using import.meta.env for everything will “just work”.

We’ve been assuming that we’re using Node as our runtime. If we use Bun instead, a lot of our problems go away.

Bun does two main things that help us out here:

  1. It automatically loads environment variables from .env files, so we don’t need to use dotenv.
  2. It loads environment variables into both process.env and import.meta.env. More specifically, Bun makes import.meta.env an alias of process.env.

To be clear about why this works and what’s happening:

  • Outside of the Astro context, Bun automatically loads our .env files and populates import.meta.env, so we’re good there.
  • Inside the Astro context, for example when running astro dev, Astro does the same.
// Now when I run my `seed.ts` script, Bun will make `DB_FILE_NAME` available.
const client = createClient({ url: import.meta.env.DB_FILE_NAME! })

So, just use import.meta.env everywhere and I’m good?

Yep, pretty much. However, there is one edge-case we have to deal with.

The --bun flag

One important difference between Bun and Node is that Node does not allow you to use import.meta in CommonJS modules. In fact, trying to access import.meta in a CommonJS module will throw an error: SyntaxError: Cannot use 'import.meta' outside a module.

Why are we talking about Node? Aren’t we using Bun anyway?

By default, Bun respects scripts, binaries, etc. that execute Node. For example, running bun run vite will execute the vite “binary”, which is actually just a JavaScript file with a Node shebang #!/usr/bin/env node. As expected, this will execute the file with Node, not Bun.

info

Astro is the same way. If you look at the astro “binary” in your node_modules, it’s just a JavaScript file with a Node shebang.

I ran into this when trying to run the drizzle-kit push command with Bun. At some point, drizzle-kit imports my drizzle.config.ts which uses import.meta.env.DB_FILE_NAME. I was getting the following error:

"import.meta" is not available with the "cjs" output format and will be empty

This usually isn’t a problem since most things are ESM nowadays. When executing something with Node, only things that are themselves CJS or load other files as CJS will have this issue.

Bun handles executing CJS files much more gracefully. It allows you to use import.meta.env inside of them without issue.

So what if we could “trick” these kinds of scripts/binaries to run with Bun instead of Node?

Bun allows you to do exactly that with the --bun, -b flag. Now when we execute something like bun run vite, it’ll run with Bun, not Node. See the Bun docs for more details.

This is the final piece of the puzzle. With this, everything works as expected.

The solution

Like I mentioned above, my suggestion is to not use astro:env at all and instead use import.meta.env everywhere. You can/should still define your environment variable schema so you can validate your expected environment variables.

tip

Update (Jun 12, 2025): Astro’s built-in environment variable validation (astro:env) has some other frustrating limitations, so I made an Astro integration as an alternative: astro-validate-env

There’s still one thing we haven’t solved, what about our typed environment variables?

Fortunately, there’s an easy way to type import.meta.env.

In a env.d.ts file:

env.d.ts
interface ImportMetaEnv {
readonly DB_FILE_NAME: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

To recap, this is what I’ve found to be the best approach:

  • Use Bun (with the --bun flag) to automatically load .env files and populate import.meta.env
  • Define all of your environment variables in your environment variable schema
  • Type your environment variables in your env.d.ts file
  • Don’t use astro:env
  • Use import.meta.env everywhere

There’s nothing wrong with using astro:env, but a Astro environment variable schema + a typed import.meta.env is basically the same thing.

Some gotchas

  • There should be a 1:1 mapping between your environment variable schema and your typed import.meta.env.
    • If have something in your import.meta.env but not in your environment variable schema and you forget to set that environment variable, you now have a variable that will show as string but is actually undefined.
  • Outside of the Astro context, the Astro-specific values will be undefined (e.g. don’t try to access import.meta.env.DEV in scripts).
  • Bun is made to be a drop-in replacement for Node, so using --bun shouldn’t break things, but it’s possible that certain dependencies/binaries may not work as expected.

This solution is definitely not perfect, but it’s a lot better than what I was doing before.