Feature Flags
Shipkit works out which features are available by checking environment variables, at build time for the UI and at request time for server code. There is no manual flag configuration: set the env vars for a feature and it turns on.
How It Works
src/config/features-config.ts runs during build. It:
- Checks which env vars are present
- Sets
buildTimeFeatures.FEATURE_NAME = true/false - Generates
NEXT_PUBLIC_FEATURE_*flags for client-side checks - Only enabled flags are injected (disabled =
undefined, notfalse)
Usage
Client-side
import { env } from "@/env";
if (env.NEXT_PUBLIC_FEATURE_STRIPE_ENABLED) {
// only runs when Stripe env vars are set
}
Build-time (Next.js config, plugins)
import { buildTimeFeatures } from "@/config/features-config";
if (buildTimeFeatures.PAYLOAD_ENABLED) {
// configure Payload plugin
}
Server actions
A flag that only hides the UI is not a kill switch. Every export of a "use server"
module is a public HTTP endpoint, so hiding the form leaves the action callable by
anyone who can POST a server action id. Guard the action as well, with
@/server/feature-guard:
import {
assertFeatureEnabled,
FEATURE_DISABLED_MESSAGE,
withFeature,
} from "@/server/feature-guard";
// Throws FeatureDisabledError. For an action that signals failure by throwing.
export async function connectGitHub() {
assertFeatureEnabled("GITHUB_API");
// ...
}
// Returns the action's own error shape. For an action that returns one.
export const submitContactForm = withFeature(
"RESEND",
() => ({ success: false, error: FEATURE_DISABLED_MESSAGE }),
async (formData: FormData) => {
// ...
}
);
The flag answers whether the action exists for this install, not who may call it. Checking the caller's session, and that they own the resource they named, is still the action's own job.
Three things to know about it:
- The guard evaluates the feature table against the live
process.env, not theNEXT_PUBLIC_FEATURE_*flags. Those are inlined at build time, so a read keyed by a variable isundefinedat runtime. The practical upside is thatDISABLE_<FEATURE>takes effect on the next request rather than the next deploy. - It fails closed, and it refuses before the session read, so a disabled feature costs a table walk and nothing else.
- The refusal message names neither the feature nor the missing key. Some of these endpoints are reachable without a session and the env shape is not theirs to know; the feature key goes to the server log.
Put the guard first, above the auth check. Leave teardown paths alone: signing out and disconnecting an account have to keep working after a feature is switched off, or a kill switch strands the user with state they cannot clear.
Two traps worth knowing before you reach for a switch:
- Password login belongs to whichever auth strategy is active. Under Auth.js the row
is
AUTH_CREDENTIALS, soDISABLE_AUTH_CREDENTIALSis the switch. Under Better Auth it isBETTER_AUTH, andDISABLE_AUTH_CREDENTIALSdoes nothing; useDISABLE_BETTER_AUTH. Runbun run doctorto see which strategy is live. - Resolve a feature key from caller input with no fallback.
signInWithOAuthActiontakes aproviderIdfrom the request. An earlier version mapped an unrecognised id to theAUTHumbrella, which is on whenever any sign-in method is, so the guard was a no-op for every id missing from the map. If you add a provider, add its row toPROVIDER_FEATURES; an unmapped id is refused, not waved through.
Env Var Mirroring
Shipkit auto-mirrors server-side public keys to NEXT_PUBLIC_ variants at build time. You can set:
STRIPE_PUBLISHABLE_KEY=pk_test_...
And NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY will be generated automatically. Only whitelisted keys are mirrored (see PUBLIC_ENV_BASE_KEYS in features-config.ts).
Mirrored keys: BUILDER_API_KEY, CLERK_PUBLISHABLE_KEY, SUPABASE_URL, SUPABASE_ANON_KEY, STRIPE_PUBLISHABLE_KEY, POSTHOG_KEY, UMAMI_WEBSITE_ID, DATAFAST_WEBSITE_ID, DATAFAST_DOMAIN, STATSIG_CLIENT_KEY, GOOGLE_ANALYTICS_ID, GOOGLE_GTM_ID, C15T_URL, VERCEL_INTEGRATION_SLUG.
APP_SECRET Derivation
If APP_SECRET is set, secrets like PAYLOAD_SECRET and BETTER_AUTH_SECRET are treated as satisfied, since they derive from the master secret. One env var, multiple features.
Complete Flag Reference
Core
| Flag | Enabled when | Disable with |
|---|---|---|
DATABASE_ENABLED | DATABASE_URL set | — |
PAYLOAD_ENABLED | Database + PAYLOAD_SECRET (or APP_SECRET) | DISABLE_PAYLOAD=true |
BUILDER_ENABLED | NEXT_PUBLIC_BUILDER_API_KEY set | DISABLE_BUILDER=true |
MDX_ENABLED | Always (default on) | DISABLE_MDX=true |
PWA_ENABLED | Always (default on) | DISABLE_PWA=true |
DEVTOOLS_ENABLED | ENABLE_DEVTOOLS=true | — |
Authentication
| Flag | Enabled when | Disable with |
|---|---|---|
AUTH_JS_ENABLED | Any Auth.js provider enabled | — |
BETTER_AUTH_ENABLED | BETTER_AUTH_SECRET (or APP_SECRET) | DISABLE_BETTER_AUTH=true |
AUTH_CLERK_ENABLED | CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY | DISABLE_AUTH_CLERK=true |
AUTH_STACK_ENABLED | STACK_PROJECT_ID + STACK_PUBLISHABLE_CLIENT_KEY + STACK_SECRET_SERVER_KEY | DISABLE_AUTH_STACK=true |
SUPABASE_AUTH_ENABLED | SUPABASE_URL + SUPABASE_ANON_KEY | DISABLE_SUPABASE_AUTH=true |
AUTH_GUEST_ENABLED | ENABLE_AUTH_GUEST=true | — |
AUTH_CREDENTIALS_ENABLED | Payload enabled | DISABLE_AUTH_CREDENTIALS=true |
AUTH_RESEND_ENABLED | Resend enabled + RESEND_FROM_EMAIL | DISABLE_AUTH_RESEND=true |
AUTH_GITHUB_ENABLED | AUTH_GITHUB_ID + AUTH_GITHUB_SECRET | DISABLE_AUTH_GITHUB=true |
AUTH_GOOGLE_ENABLED | AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET | DISABLE_AUTH_GOOGLE=true |
AUTH_DISCORD_ENABLED | AUTH_DISCORD_ID + AUTH_DISCORD_SECRET | DISABLE_AUTH_DISCORD=true |
AUTH_TWITTER_ENABLED | AUTH_TWITTER_ID + AUTH_TWITTER_SECRET | DISABLE_AUTH_TWITTER=true |
AUTH_GITLAB_ENABLED | AUTH_GITLAB_ID + AUTH_GITLAB_SECRET | DISABLE_AUTH_GITLAB=true |
AUTH_BITBUCKET_ENABLED | AUTH_BITBUCKET_ID + AUTH_BITBUCKET_SECRET | DISABLE_AUTH_BITBUCKET=true |
AUTH_VERCEL_ENABLED | VERCEL_CLIENT_ID + VERCEL_CLIENT_SECRET | DISABLE_AUTH_VERCEL=true |
AUTH_ENABLED | Any auth provider enabled | — |
Payments
| Flag | Enabled when | Disable with |
|---|---|---|
LEMONSQUEEZY_ENABLED | LEMONSQUEEZY_API_KEY + LEMONSQUEEZY_STORE_ID | DISABLE_LEMONSQUEEZY=true |
STRIPE_ENABLED | STRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEY | DISABLE_STRIPE=true |
POLAR_ENABLED | POLAR_ACCESS_TOKEN | DISABLE_POLAR=true |
Storage
| Flag | Enabled when | Disable with |
|---|---|---|
S3_ENABLED | AWS_REGION + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_BUCKET_NAME | DISABLE_S3=true |
VERCEL_BLOB_ENABLED | VERCEL_BLOB_READ_WRITE_TOKEN | DISABLE_VERCEL_BLOB=true |
FILE_UPLOAD_ENABLED | S3 or Vercel Blob enabled | — |
Analytics
| Flag | Enabled when | Disable with |
|---|---|---|
POSTHOG_ENABLED | POSTHOG_KEY | DISABLE_POSTHOG=true |
UMAMI_ENABLED | UMAMI_WEBSITE_ID | DISABLE_UMAMI=true |
DATAFAST_ENABLED | DATAFAST_WEBSITE_ID | DISABLE_DATAFAST=true |
STATSIG_ENABLED | STATSIG_CLIENT_KEY | DISABLE_STATSIG=true |
GOOGLE_ANALYTICS_ENABLED | GOOGLE_ANALYTICS_ID | DISABLE_GOOGLE_ANALYTICS=true |
GOOGLE_TAG_MANAGER_ENABLED | GOOGLE_GTM_ID | DISABLE_GOOGLE_TAG_MANAGER=true |
Infrastructure
| Flag | Enabled when | Disable with |
|---|---|---|
REDIS_ENABLED | UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN | DISABLE_REDIS=true |
VERCEL_INTEGRATION_ENABLED | VERCEL_INTEGRATION_SLUG + VERCEL_CLIENT_ID + VERCEL_CLIENT_SECRET | DISABLE_VERCEL_INTEGRATION=true |
AI / External Services
| Flag | Enabled when | Disable with |
|---|---|---|
OPENAI_ENABLED | OPENAI_API_KEY | DISABLE_OPENAI=true |
ANTHROPIC_ENABLED | ANTHROPIC_API_KEY | DISABLE_ANTHROPIC=true |
GITHUB_API_ENABLED | GITHUB_ACCESS_TOKEN | DISABLE_GITHUB_API=true |
RESEND_ENABLED | RESEND_API_KEY | DISABLE_RESEND=true |
Other
| Flag | Enabled when | Disable with |
|---|---|---|
LIGHT_MODE_ENABLED | Always (default on) | DISABLE_LIGHT_MODE=true |
DARK_MODE_ENABLED | Always (default on) | DISABLE_DARK_MODE=true |
C15T_ENABLED | C15T_URL | DISABLE_C15T=true |
TURNSTILE_ENABLED | TURNSTILE_SITE_KEY + TURNSTILE_SECRET_KEY | — |
Adding a New Feature Flag
- Declare the feature in
src/config/features-table.ts. One entry, naming the keys it needs and its kill switch:
MY_FEATURE: {
label: "My Feature",
env: ["MY_API_KEY"],
disable: "DISABLE_MY_FEATURE",
docs: "https://example.com/api-keys",
},
That's the only declaration site. features-config.ts, bun run doctor and the server guard all read this table, so a flag you assign by hand anywhere else is invisible to all three. FeatureKey is keyof typeof table, which means a feature that isn't in the table can't be guarded at all: assertFeatureEnabled("MY_FEATURE") won't compile.
-
Add the env vars and the generated flag to
src/env.ts.MY_API_KEYgoes in the server schema;NEXT_PUBLIC_FEATURE_MY_FEATURE_ENABLEDgoes in both the client schema andruntimeEnv. Add the flag name totests/unit/config/features-table.test.tsas well. That test pins the whole flag set, and when it fails you get an array diff rather than an explanation. -
Guard the server code. Every action that reaches the feature needs
assertFeatureEnabled("MY_FEATURE")orwithFeature(...). Skip this andDISABLE_MY_FEATUREis only a UI hint. -
Use the client flag for presentation:
env.NEXT_PUBLIC_FEATURE_MY_FEATURE_ENABLED.
Then run bun run doctor. Your feature should read on, or waiting on a key it names, or off. It's the fastest way to find out whether the table entry says what you meant.
Key Files
| File | Purpose |
|---|---|
src/config/features-table.ts | Where every feature is declared |
src/config/features-config.ts | Evaluates the table into build-time flags |
src/server/feature-guard.ts | Refuses a server action when its feature is off |
src/env.ts | T3 Env schema (runtime validation) |