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:

  1. Checks which env vars are present
  2. Sets buildTimeFeatures.FEATURE_NAME = true/false
  3. Generates NEXT_PUBLIC_FEATURE_* flags for client-side checks
  4. Only enabled flags are injected (disabled = undefined, not false)

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 the NEXT_PUBLIC_FEATURE_* flags. Those are inlined at build time, so a read keyed by a variable is undefined at runtime. The practical upside is that DISABLE_<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, so DISABLE_AUTH_CREDENTIALS is the switch. Under Better Auth it is BETTER_AUTH, and DISABLE_AUTH_CREDENTIALS does nothing; use DISABLE_BETTER_AUTH. Run bun run doctor to see which strategy is live.
  • Resolve a feature key from caller input with no fallback. signInWithOAuthAction takes a providerId from the request. An earlier version mapped an unrecognised id to the AUTH umbrella, 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 to PROVIDER_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

FlagEnabled whenDisable with
DATABASE_ENABLEDDATABASE_URL set—
PAYLOAD_ENABLEDDatabase + PAYLOAD_SECRET (or APP_SECRET)DISABLE_PAYLOAD=true
BUILDER_ENABLEDNEXT_PUBLIC_BUILDER_API_KEY setDISABLE_BUILDER=true
MDX_ENABLEDAlways (default on)DISABLE_MDX=true
PWA_ENABLEDAlways (default on)DISABLE_PWA=true
DEVTOOLS_ENABLEDENABLE_DEVTOOLS=true—

Authentication

FlagEnabled whenDisable with
AUTH_JS_ENABLEDAny Auth.js provider enabled—
BETTER_AUTH_ENABLEDBETTER_AUTH_SECRET (or APP_SECRET)DISABLE_BETTER_AUTH=true
AUTH_CLERK_ENABLEDCLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEYDISABLE_AUTH_CLERK=true
AUTH_STACK_ENABLEDSTACK_PROJECT_ID + STACK_PUBLISHABLE_CLIENT_KEY + STACK_SECRET_SERVER_KEYDISABLE_AUTH_STACK=true
SUPABASE_AUTH_ENABLEDSUPABASE_URL + SUPABASE_ANON_KEYDISABLE_SUPABASE_AUTH=true
AUTH_GUEST_ENABLEDENABLE_AUTH_GUEST=true—
AUTH_CREDENTIALS_ENABLEDPayload enabledDISABLE_AUTH_CREDENTIALS=true
AUTH_RESEND_ENABLEDResend enabled + RESEND_FROM_EMAILDISABLE_AUTH_RESEND=true
AUTH_GITHUB_ENABLEDAUTH_GITHUB_ID + AUTH_GITHUB_SECRETDISABLE_AUTH_GITHUB=true
AUTH_GOOGLE_ENABLEDAUTH_GOOGLE_ID + AUTH_GOOGLE_SECRETDISABLE_AUTH_GOOGLE=true
AUTH_DISCORD_ENABLEDAUTH_DISCORD_ID + AUTH_DISCORD_SECRETDISABLE_AUTH_DISCORD=true
AUTH_TWITTER_ENABLEDAUTH_TWITTER_ID + AUTH_TWITTER_SECRETDISABLE_AUTH_TWITTER=true
AUTH_GITLAB_ENABLEDAUTH_GITLAB_ID + AUTH_GITLAB_SECRETDISABLE_AUTH_GITLAB=true
AUTH_BITBUCKET_ENABLEDAUTH_BITBUCKET_ID + AUTH_BITBUCKET_SECRETDISABLE_AUTH_BITBUCKET=true
AUTH_VERCEL_ENABLEDVERCEL_CLIENT_ID + VERCEL_CLIENT_SECRETDISABLE_AUTH_VERCEL=true
AUTH_ENABLEDAny auth provider enabled—

Payments

FlagEnabled whenDisable with
LEMONSQUEEZY_ENABLEDLEMONSQUEEZY_API_KEY + LEMONSQUEEZY_STORE_IDDISABLE_LEMONSQUEEZY=true
STRIPE_ENABLEDSTRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEYDISABLE_STRIPE=true
POLAR_ENABLEDPOLAR_ACCESS_TOKENDISABLE_POLAR=true

Storage

FlagEnabled whenDisable with
S3_ENABLEDAWS_REGION + AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_BUCKET_NAMEDISABLE_S3=true
VERCEL_BLOB_ENABLEDVERCEL_BLOB_READ_WRITE_TOKENDISABLE_VERCEL_BLOB=true
FILE_UPLOAD_ENABLEDS3 or Vercel Blob enabled—

Analytics

FlagEnabled whenDisable with
POSTHOG_ENABLEDPOSTHOG_KEYDISABLE_POSTHOG=true
UMAMI_ENABLEDUMAMI_WEBSITE_IDDISABLE_UMAMI=true
DATAFAST_ENABLEDDATAFAST_WEBSITE_IDDISABLE_DATAFAST=true
STATSIG_ENABLEDSTATSIG_CLIENT_KEYDISABLE_STATSIG=true
GOOGLE_ANALYTICS_ENABLEDGOOGLE_ANALYTICS_IDDISABLE_GOOGLE_ANALYTICS=true
GOOGLE_TAG_MANAGER_ENABLEDGOOGLE_GTM_IDDISABLE_GOOGLE_TAG_MANAGER=true

Infrastructure

FlagEnabled whenDisable with
REDIS_ENABLEDUPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKENDISABLE_REDIS=true
VERCEL_INTEGRATION_ENABLEDVERCEL_INTEGRATION_SLUG + VERCEL_CLIENT_ID + VERCEL_CLIENT_SECRETDISABLE_VERCEL_INTEGRATION=true

AI / External Services

FlagEnabled whenDisable with
OPENAI_ENABLEDOPENAI_API_KEYDISABLE_OPENAI=true
ANTHROPIC_ENABLEDANTHROPIC_API_KEYDISABLE_ANTHROPIC=true
GITHUB_API_ENABLEDGITHUB_ACCESS_TOKENDISABLE_GITHUB_API=true
RESEND_ENABLEDRESEND_API_KEYDISABLE_RESEND=true

Other

FlagEnabled whenDisable with
LIGHT_MODE_ENABLEDAlways (default on)DISABLE_LIGHT_MODE=true
DARK_MODE_ENABLEDAlways (default on)DISABLE_DARK_MODE=true
C15T_ENABLEDC15T_URLDISABLE_C15T=true
TURNSTILE_ENABLEDTURNSTILE_SITE_KEY + TURNSTILE_SECRET_KEY—

Adding a New Feature Flag

  1. 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.

  1. Add the env vars and the generated flag to src/env.ts. MY_API_KEY goes in the server schema; NEXT_PUBLIC_FEATURE_MY_FEATURE_ENABLED goes in both the client schema and runtimeEnv. Add the flag name to tests/unit/config/features-table.test.ts as well. That test pins the whole flag set, and when it fails you get an array diff rather than an explanation.

  2. Guard the server code. Every action that reaches the feature needs assertFeatureEnabled("MY_FEATURE") or withFeature(...). Skip this and DISABLE_MY_FEATURE is only a UI hint.

  3. 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

FilePurpose
src/config/features-table.tsWhere every feature is declared
src/config/features-config.tsEvaluates the table into build-time flags
src/server/feature-guard.tsRefuses a server action when its feature is off
src/env.tsT3 Env schema (runtime validation)