Skip to main content
mf² uses environment variables for configuration. This page covers the keys that unlock the core services and the ones that add extra features.
All environment variables are optional. A blank or missing value simply disables that integration: features like Stripe, PostHog, BaseHub CMS, email, and feature flags gracefully degrade instead of crashing. Nothing is required just to boot; validation only fails when a variable is set to a malformed value.

Quick start

These three services unlock the core of mf²: sign-in (Clerk), data (Convex), and payments (Stripe). None of them are required just to run bun run dev.

1. Authentication (Clerk)

Add to your app’s environment (e.g. Vercel dashboard or local .env.local):
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
CLERK_SECRET_KEY="sk_test_..."
1
Create an application in the Clerk Dashboard
2
Go to API Keys
3
Copy the Publishable key (starts with pk_) and Secret key (starts with sk_)

2. Backend (Convex)

Running bunx convex dev generates the deployment URL and writes it to .env.local automatically.
NEXT_PUBLIC_CONVEX_URL="https://your-project.convex.cloud"

3. Payments (Stripe)

STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
1
Get your keys from the Stripe Dashboard
2
For local webhooks, run:
stripe listen --forward-to localhost:3000/api/webhook/stripe
The CLI prints a signing secret to use as STRIPE_WEBHOOK_SECRET.
You can now run bun run dev and the app works with auth, backend, and payments.

Additional features

Add these as needed.

Email (Resend)

RESEND_TOKEN="re_..."
RESEND_FROM="noreply@yourdomain.com"
Get your API key from Resend

Analytics (PostHog)

NEXT_PUBLIC_POSTHOG_KEY="phc_..."
NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com"
Get your keys from PostHog

Analytics (Google)

NEXT_PUBLIC_GA_MEASUREMENT_ID="G-..."
Create a GA4 property

Error tracking (Sentry)

SENTRY_DSN="https://..."
SENTRY_ORG="your-org"
SENTRY_PROJECT="your-project"
Get your DSN from Sentry

Observability (BetterStack)

BETTERSTACK_API_KEY="..."
BETTERSTACK_URL="..."
Get your API key from BetterStack

Security (Arcjet)

ARCJET_KEY="ajkey_..."
Get your key from Arcjet

Webhooks (Svix)

SVIX_TOKEN="..."
Get your token from Svix

Notifications (Knock)

KNOCK_API_KEY="..."
KNOCK_SECRET_API_KEY="..."
KNOCK_FEED_CHANNEL_ID="..."
NEXT_PUBLIC_KNOCK_API_KEY="..."
NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID="..."
Get your keys from Knock

Collaboration (Liveblocks)

LIVEBLOCKS_SECRET="sk_..."
Get your secret from Liveblocks

CMS (BaseHub)

BASEHUB_TOKEN="bshb_..."
Get your token from BaseHub

AI (Vercel AI Gateway)

AI_GATEWAY_API_KEY="..."
AI_GATEWAY_URL="..."

Clerk Webhooks

CLERK_WEBHOOK_SECRET="whsec_..."
See Convex Provider: Configure the webhook in Clerk for step-by-step setup.

Type safety

mf² validates environment variables at build time using @t3-oss/env-nextjs. Each app has an env.ts file that defines the schema:
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
  server: {
    CLERK_SECRET_KEY: z.string().startsWith('sk_').optional(),
    STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
    STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_').optional(),
  },
  client: {
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().startsWith('pk_').optional(),
    NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(),
  },
  runtimeEnv: {
    CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
    NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
  },
  emptyStringAsUndefined: true,
});
Every schema sets emptyStringAsUndefined, so a blank value behaves exactly like a missing one: the integration is disabled and validation passes. Validation fails only when a variable is set to a malformed value, such as a Clerk secret that does not start with sk_, and the build stops with a clear error message. Import env instead of accessing process.env directly:
import { env } from '@/env';

const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Next.js exposes variables prefixed with NEXT_PUBLIC_ to the browser. Never put secrets in NEXT_PUBLIC_ variables.
Be specific with validation. If a vendor secret starts with sk_, validate it as z.string().startsWith('sk_').optional(). This catches misconfiguration at build time instead of runtime.

Adding a new variable

  1. Add the variable to the relevant .env file
  2. Add validation to the server or client object in the app’s env.ts file
Example:
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
  server: {
    MY_NEW_SECRET: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_MY_VALUE: z.string().optional(),
  },
  runtimeEnv: {
    MY_NEW_SECRET: process.env.MY_NEW_SECRET,
    NEXT_PUBLIC_MY_VALUE: process.env.NEXT_PUBLIC_MY_VALUE,
  },
  emptyStringAsUndefined: true,
});
Keep a variable required (no .optional()) only when the feature cannot work without it; blank values for required keys fail validation.
  1. Import from @/env in your code
  2. Add it to .env.example so teammates know it exists

Env Scripts

mf² includes a Bun script for managing environment files across the monorepo:
bun run env:check    # List env files and keys that are still blank
bun run env:push     # Sync env vars to Vercel and Convex
.env.example files are the source of truth for which variables each app and package needs. The CLI creates both .env.local (for development) and .env.production (for production) from each .env.example at scaffold time, so there is no init step; you just fill in keys as you need them. env:check compares each .env.example against its .env.local and .env.production, reporting blank or missing keys grouped by app. If an env file is missing entirely (for example after deleting it, or if you cloned the template repo instead of scaffolding), it tells you which .env.example to copy to recreate it. env:push syncs variables to your deployment platforms:
SourceVercel targetConvex target
.env.localdevelopment + previewdev deployment
.env.productionproductionprod deployment
The script filters automatically: NEXT_PUBLIC_* vars skip Convex (client-side only), CONVEX_DEPLOYMENT and VERCEL_* vars are skipped (managed by platforms), and empty or localhost values are ignored. Before pushing, link each app to its Vercel project:
cd apps/app && vercel link && cd ../..
cd apps/web && vercel link && cd ../..
Then sync everything:
bun run env:push