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.

Hono-native: 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.

.env
vite.config.ts
src
app
api
ƒhello.ts
/api/hello
vite.config.ts
// 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
// 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 app
Plugin is already registered on scaffold - no manual loadEnv 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
// 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 app

The pattern in three steps:

src/app/api/pattern.ts
// 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 app

Environment Prefixes

Vite loads .env files from your project root. The prefix of each variable decides where it ends up.

.env
.env.local
.env.development
.env.production

BINI_ - Client-side vars

BINI_ variables are exposed to import.meta.env. Use them for public client-side config.

.env
# .env
BINI_PUBLIC_API_URL=https://api.example.com
src/app/page.tsx
export 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
# .env
VITE_ANALYTICS_ID=UA-XXXX
src/app/page.tsx
export 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
# .env
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...
src/app/api/secrets.ts
// 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 app

Prefix Summary

PrefixExposed to browserMirrored to process.envUse for
BINI_YesNoPublic client config
VITE_YesNoPublic client config
No prefixNoYes (dev/preview)Secrets - server only
Critical: Never put secrets in 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.

PlatformRuntimeHow Hono reads it
Node.jsNodeprocess.env
BunBunprocess.env
Vercel EdgeV8 isolateprocess.env
Netlify EdgeDenoDeno.env.get()
Cloudflare WorkersV8 isolateCF bindings via c.env
Deno DeployDenoDeno.env.get()
Cloudflare note: Secrets set via 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_ and VITE_ prefixed vars to import.meta.env
  • Mirrors non-prefixed .env values into process.env during vite dev / vite preview
bini-env/index.ts
// 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:

>_Terminal
  ß 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.

Precedence: A value already present in 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
// 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 app

requireEnv(c, key)

Returns string. Throws immediately if the variable is missing or empty.

src/app/api/send-email.ts
// 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 app

On failure, the terminal will show:

>_Terminal
[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
// 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 app

HonoContext

Exported type (Context from Hono). Use it to type helper functions that group env reads.

src/app/api/db.ts
// 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 app

Security Best Practices

Rule 1: Never Prefix Secrets

.env
# .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
# .env

# GOOD - Public data
BINI_API_URL=https://api.example.com
VITE_GA_ID=UA-XXXXX

Rule 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

MetricDevProd
File reads1 (loadEnv, cached by Vite)0
Runtime cost~0ms (mirror runs once at server start)0
Bundle impactMinimalTree-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

ProblemSolution
Env var undefined in productionSet variables in your hosting platform env dashboard (Vercel, Netlify, Cloudflare, etc.).
Works in dev, undefined in prodLocal 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 devCheck 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 .envIf 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 folderbiniEnv() reads from your Vite envDir if set, otherwise root, otherwise the working directory. Double check envDir/root in vite.config.ts.
Cloudflare secret not foundSecrets 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 HonoContextCast once per handler: const ctx = c as any
Types not foundAdd /// <reference types="vite/client" /> to your tsconfig.json or entry file.

Complete Example

.env
src
app
page.tsx
api
ƒconfig.ts
/
/api/config
.env
# .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
// 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
// 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
Was this helpful?