adamhl.dev

Handling environment variables in an Astro project

8 min read

It's more complicated than you think.


Leave a reaction or comment
danger

Astro has since updated their environment variable handling, so some of the information in this post may be outdated.


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:

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

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

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:

// 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:

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

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