Environment Variables
Hono-native environment variable system for Bini.js - works across Node.js, Bun, Deno, Vercel Edge, Netlify Edge, and Cloudflare Workers.
bini-env is installed and configured by default in every Bini.js project. It reads env vars from the Hono request context, so variables are always resolved from the correct runtime binding - no platform-specific code needed.
getEnv(c, key) / requireEnv(c, key) read directly from the Hono request context. Zero dotenv - no .env parsing at runtime; vars come from the host platform. Vite handles .env loading during development.Quick Start
bini-env plugin is already registered when you scaffold a new Bini.js project - nothing to configure in vite.config.ts. Just start using getEnv and requireEnv in your API routes.
// vite.config.ts - already configured on scaffold
// biniEnv() is included by default - no setup needed
import { defineConfig } from 'vite'
import { biniEnv } from 'bini-env'
export default defineConfig({
plugins: [biniEnv()],
})Read env vars in your Hono handlers
// src/app/api/hello.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.post('/hello', async (c) => {
try {
const ctx = c as any
const apiKey = requireEnv(ctx, 'MY_API_KEY')
const appName = getEnv(ctx, 'APP_NAME') ?? 'World'
return c.json({ message: `Hello, ${appName}!` })
} catch (error: any) {
if (error.message?.includes('[bini-env] Missing required')) {
return c.json({ error: error.message }, 500)
}
return c.json({ error: 'Something went wrong.' }, 500)
}
})
export default apploadEnv loop in vite.config.ts needed. Your secret just needs to exist in .env with no prefix, and requireEnv will find it during dev and preview.Usage Pattern
Always pass c explicitly. Cast it once at the top of the handler, then use ctx throughout.
// src/app/api/example.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.post('/example', async (c) => {
try {
const ctx = c as any
const dbUrl = requireEnv(ctx, 'DATABASE_URL')
const apiKey = requireEnv(ctx, 'STRIPE_SECRET_KEY')
const model = getEnv(ctx, 'AI_MODEL') ?? 'gpt-4o'
const region = getEnv(ctx, 'AWS_REGION') ?? 'us-east-1'
const maxRetries = parseInt(getEnv(ctx, 'MAX_RETRIES') ?? '3')
const debug = getEnv(ctx, 'DEBUG_MODE') === 'true'
return c.json({ model, region, maxRetries, debug })
} catch (error: any) {
if (error.message?.includes('[bini-env] Missing required')) {
return c.json({ error: error.message }, 500)
}
return c.json({ error: 'Something went wrong.' }, 500)
}
})
export default appThe pattern in three steps:
// src/app/api/pattern.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.get('/pattern', (c) => {
const ctx = c as any // 1. cast once, at the top
const secret = requireEnv(ctx, 'KEY') // 2. throws if missing
const mode = getEnv(ctx, 'MODE') ?? 'default' // 3. optional with default
return c.json({ ok: !!secret, mode })
})
export default appEnvironment Prefixes
Vite loads .env files from your project root. The prefix of each variable decides where it ends up.
BINI_ - Client-side vars
BINI_ variables are exposed to import.meta.env. Use them for public client-side config.
# .env
BINI_PUBLIC_API_URL=https://api.example.comexport default function HomePage() {
const apiUrl = import.meta.env.BINI_PUBLIC_API_URL
return <p>API: {apiUrl}</p>
}VITE_ - Public client vars
VITE_ is Vite's built-in prefix. Any var starting with VITE_ is bundled into your client-side JavaScript.
# .env
VITE_ANALYTICS_ID=UA-XXXXexport default function HomePage() {
const analyticsId = import.meta.env.VITE_ANALYTICS_ID
return <p>Analytics: {analyticsId}</p>
}No prefix - Secrets (server only)
Variables without a prefix are NOT exposed to the browser. During dev/preview they are mirrored into process.env automatically, and read via getEnv(ctx, key) in API routes.
# .env
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...// src/app/api/secrets.ts
import { Hono } from 'hono'
import { requireEnv } from 'bini-env'
const app = new Hono()
app.get('/secrets', (c) => {
const ctx = c as any
const dbUrl = requireEnv(ctx, 'DATABASE_URL')
return c.json({ dbConnected: !!dbUrl })
})
export default appPrefix Summary
| Prefix | Exposed to browser | Mirrored to process.env | Use for |
|---|---|---|---|
| BINI_ | Yes | No | Public client config |
| VITE_ | Yes | No | Public client config |
| No prefix | No | Yes (dev/preview) | Secrets - server only |
BINI_* or VITE_* variables - both are exposed to the browser. Use un-prefixed variables for secrets and read them with getEnv(ctx, key) inside API route handlers only.Platform Support
getEnv and requireEnv delegate to Hono's env(c) adapter, which reads from the correct source on every supported platform automatically. Your code never changes regardless of where it deploys.
| Platform | Runtime | How Hono reads it |
|---|---|---|
| Node.js | Node | process.env |
| Bun | Bun | process.env |
| Vercel Edge | V8 isolate | process.env |
| Netlify Edge | Deno | Deno.env.get() |
| Cloudflare Workers | V8 isolate | CF bindings via c.env |
| Deno Deploy | Deno | Deno.env.get() |
wrangler secret put are only available inside the fetch handler via c.env. getEnv(ctx, key) reads them correctly as long as you pass c.How It Works
The biniEnv() plugin does two things:
- Tells Vite to expose
BINI_andVITE_prefixed vars toimport.meta.env - Mirrors non-prefixed
.envvalues intoprocess.envduringvite dev/vite preview
// simplified view of the plugin
export function biniEnv() {
return {
name: 'bini-env',
config(userConfig, { command }) {
if (command === 'serve') {
const envDir = userConfig.envDir ?? userConfig.root ?? process.cwd()
// mirrors non-prefixed, non-empty .env values into process.env
// silent on success, warns on failure - no opt-out
}
return { envPrefix: ['BINI_', 'VITE_'] }
},
}
}The prefix list is fixed - there is no option to add more prefixes. Only BINI_ and VITE_ are ever exposed to the browser.
On server start you will see:
ß Bini.js (dev) ➜ Environments: .env.local, .env ➜ Local: http://localhost:3000/ ➜ Network: http://192.168.1.7:3000/
Vite handles everything natively: loading .env files, watching, restarting, injecting prefixed vars, and HMR. bini-env does not reimplement any of that.
Zero dotenv: dotenv is never used at runtime. In production, vars are set in your hosting platform's environment config.
process.env (set by your OS, shell, or CI) always wins. .env file values only fill in variables that are not already set.API Reference
getEnv(c, key)
Returns string | undefined. Reads from the Hono request context.
// src/app/api/config.ts
import { Hono } from 'hono'
import { getEnv } from 'bini-env'
const app = new Hono()
app.get('/config', async (c) => {
const ctx = c as any
const region = getEnv(ctx, 'AWS_REGION') ?? 'us-east-1'
const logLevel = getEnv(ctx, 'LOG_LEVEL') ?? 'info'
const debug = getEnv(ctx, 'DEBUG_MODE') === 'true'
return c.json({ region, logLevel, debug })
})
export default apprequireEnv(c, key)
Returns string. Throws immediately if the variable is missing or empty.
// src/app/api/send-email.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.post('/send-email', async (c) => {
try {
const ctx = c as any
const smtpHost = requireEnv(ctx, 'SMTP_HOST')
const smtpPass = requireEnv(ctx, 'SMTP_PASS')
const smtpPort = parseInt(getEnv(ctx, 'SMTP_PORT') ?? '587')
// ... send email
return c.json({ sent: true })
} catch (error: any) {
if (error.message?.includes('[bini-env] Missing required')) {
return c.json({ error: error.message }, 500)
}
return c.json({ error: 'Failed to send email.' }, 500)
}
})
export default appOn failure, the terminal will show:
[bini-env] error Missing required environment variable: "SMTP_HOST" -> Set it in your platform's env config or hosting dashboard.
biniEnv()
Vite plugin. Takes no options. It is already registered in the vite.config shown in Quick Start.
There is nothing to configure - no prefix list to extend, no flag to disable the process.env mirror. The prefix list is fixed to ['BINI_', 'VITE_'].
biniLogger
Vite-style terminal logger. Use it in your own Bini.js plugins or server-side code.
// src/app/api/health.ts
import { Hono } from 'hono'
import { getEnv, biniLogger } from 'bini-env'
const app = new Hono()
app.get('/health', (c) => {
const ctx = c as any
try {
const region = getEnv(ctx, 'AWS_REGION')
biniLogger.info('Server ready')
if (!region) biniLogger.warn('Missing optional var')
return c.json({ ok: true })
} catch (error) {
biniLogger.error('Something broke', error)
return c.json({ ok: false }, 500)
}
})
export default appHonoContext
Exported type (Context from Hono). Use it to type helper functions that group env reads.
// src/app/api/db.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
import type { HonoContext } from 'bini-env'
function readDbConfig(c: HonoContext) {
const ctx = c as any
return {
url: requireEnv(ctx, 'DATABASE_URL'),
poolSize: parseInt(getEnv(ctx, 'DB_POOL_SIZE') ?? '10'),
ssl: getEnv(ctx, 'DB_SSL') !== 'false',
}
}
const app = new Hono()
app.get('/db', (c) => {
const db = readDbConfig(c)
return c.json({ poolSize: db.poolSize, ssl: db.ssl })
})
export default appSecurity Best Practices
Rule 1: Never Prefix Secrets
# .env
# BAD - This will be exposed to the browser!
BINI_DATABASE_URL=postgres://...
# GOOD - Not exposed, mirrored into process.env for server-side use
DATABASE_URL=postgres://...Rule 2: Use BINI_ or VITE_ for Public Data
# .env
# GOOD - Public data
BINI_API_URL=https://api.example.com
VITE_GA_ID=UA-XXXXXRule 3: Do not Leave Secret Placeholders Empty
An empty value (API_KEY=) is skipped by the process.env mirror, so requireEnv will correctly throw instead of silently succeeding with an empty string.
Performance
| Metric | Dev | Prod |
|---|---|---|
| File reads | 1 (loadEnv, cached by Vite) | 0 |
| Runtime cost | ~0ms (mirror runs once at server start) | 0 |
| Bundle impact | Minimal | Tree-shaken |
No dotenv. No per-request disk reads. getEnv is a direct call to Hono's adapter on every invocation - request-scoped and correct.
Troubleshooting
| Problem | Solution |
|---|---|
| Env var undefined in production | Set variables in your hosting platform env dashboard (Vercel, Netlify, Cloudflare, etc.). |
| Works in dev, undefined in prod | Local dev works because biniEnv() mirrors non-prefixed vars into process.env automatically. Production requires platform-level configuration. |
| My .env value is not taking effect in dev | Check your shell and CI environment first - the mirror never overrides a variable that is already set. Also check the value is not empty (KEY=). |
| requireEnv still throws even though my key is in .env | If the value is KEY= with nothing after the =, it is treated as unset and skipped by design. Give it a real value. |
| bini-env is not reading my .env from the right folder | biniEnv() reads from your Vite envDir if set, otherwise root, otherwise the working directory. Double check envDir/root in vite.config.ts. |
| Cloudflare secret not found | Secrets set via wrangler secret put are only available via c.env. Ensure you are passing c to the function. |
| TypeScript error: Context not assignable to HonoContext | Cast once per handler: const ctx = c as any |
| Types not found | Add /// <reference types="vite/client" /> to your tsconfig.json or entry file. |
Complete Example
# .env
BINI_PUBLIC_API_URL=https://api.example.com
VITE_APP_NAME=My App
DATABASE_URL=postgres://localhost:5432/mydb
JWT_SECRET=your_jwt_secret// src/app/page.tsx
export default function HomePage() {
const apiUrl = import.meta.env.BINI_PUBLIC_API_URL
const appName = import.meta.env.VITE_APP_NAME
return <h1>{appName}</h1>
}// src/app/api/config.ts
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.get('/config', (c) => {
const ctx = c as any
const dbUrl = requireEnv(ctx, 'DATABASE_URL')
const jwtSecret = requireEnv(ctx, 'JWT_SECRET')
const debug = getEnv(ctx, 'DEBUG_MODE') === 'true'
return c.json({ debug, dbConnected: !!dbUrl })
})
export default app