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:
- Environment variables are automatically loaded from our
.env
files - Environment variables are validated before running/building the application
- Environment variables can be accessed without having to check for
undefined
But, like I mentioned, I ran into some issues.
Importing from astro:env
is only possible when in the Astro context.
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:
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.
import { db } from "./db" // Throws an errorimport { 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.
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:
- In the Astro context, even though it loads our
.env
file,process.env
is not populated with our environment variables. - When run as a script, Node doesn’t load our
.env
file at all.
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:
astro:env
for my typed environment variables (only accessible in the Astro context)process.env
(plusdotenv
) for environment variables I need to access both in and outside of the Astro context (e.g. in scripts)import.meta.env
for environment variables specific to Astro
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:
- It automatically loads environment variables from
.env
files, so we don’t need to usedotenv
. - It loads environment variables into both
process.env
andimport.meta.env
. More specifically, Bun makesimport.meta.env
an alias ofprocess.env
.
To be clear about why this works and what’s happening:
- Outside of the Astro context, Bun automatically loads our
.env
files and populatesimport.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.
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.
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:
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 populateimport.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 asstring
but is actuallyundefined
.
- If have something in your
- Outside of the Astro context, the Astro-specific values will be
undefined
(e.g. don’t try to accessimport.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.