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_..."
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_..."
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)
Get your key from Arcjet
Webhooks (Svix)
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)
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
- Add the variable to the relevant
.env file
- 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.
- Import from
@/env in your code
- 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:
| Source | Vercel target | Convex target |
|---|
.env.local | development + preview | dev deployment |
.env.production | production | prod 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: