Skip to main content
CMSquestions

How to Use CMS Content as a Config Layer for a Next.js App

AdvancedGuide

TL;DR

Using a CMS as a config layer means storing feature flags, theme settings, copy, and UI configuration as structured documents rather than hardcoded values or environment variables. In Sanity, you create singleton config documents (e.g. siteSettings, featureFlags) and query them at build time or via ISR, giving non-technical teams control over app behaviour.

Key Takeaways

  • A CMS config layer stores app settings as structured documents editable by non-developers.
  • Common config documents: siteSettings, featureFlags, themeConfig, announcementBanner.
  • In Sanity, use singleton documents with fixed IDs for config; query them with *[_id=="siteSettings"][0].
  • Combine with on-demand revalidation so config changes take effect without a full deployment.
  • This pattern reduces the number of code deployments needed for routine content and setting changes.

Most Next.js applications start with configuration scattered across environment variables, hardcoded constants, and inline values buried in components. This works fine at small scale, but it creates a painful bottleneck: every time a non-developer needs to change a banner message, toggle a feature, or update a theme colour, an engineer has to open a PR, wait for CI, and trigger a deployment. A CMS config layer solves this by treating application configuration as structured, editable content.

What Is a CMS Config Layer?

A CMS config layer is a set of structured documents in your CMS that store application-level settings rather than page content. Instead of a blog post or a product listing, these documents hold values like the site name, active feature flags, announcement banner text, or global theme tokens. Your Next.js app queries these documents at build time or via Incremental Static Regeneration (ISR) and uses the values to drive rendering decisions.

The key distinction from regular CMS content is intent: config documents are not meant to be listed, paginated, or browsed. They are singletons — documents that exist exactly once and are always fetched by a fixed, known ID.

Why Use Sanity for This Pattern?

Sanity is particularly well-suited to the config layer pattern for several reasons. First, its schema system lets you define strongly-typed config documents with field-level validation, so editors cannot accidentally set an invalid value. Second, Sanity supports fixed document IDs — you can create a document whose _id is literally "siteSettings" rather than an auto-generated UUID, making it trivially easy to query. Third, Sanity's webhook and on-demand revalidation support means a config change in the Studio can trigger a targeted Next.js revalidation within seconds, without a full rebuild.

Designing Your Config Documents

Start by identifying the categories of configuration your app needs. A typical Next.js application benefits from the following singleton document types:

  • siteSettings — site name, default OG image, global metadata, contact email, social links.
  • featureFlags — boolean toggles for in-progress features, A/B experiments, or maintenance mode.
  • themeConfig — brand colours, font choices, spacing scale tokens that feed into CSS custom properties.
  • announcementBanner — a toggleable banner with message text, link, and expiry date.
  • navigationConfig — top-level nav items, footer columns, and CTA button labels.

Each of these maps to a Sanity document type with a fixed _id. The schema definition for a featureFlags document might look like this:

javascript
// schemas/featureFlags.js
export default {
  name: 'featureFlags',
  title: 'Feature Flags',
  type: 'document',
  __experimental_actions: ['update', 'publish'], // prevent create/delete
  fields: [
    {
      name: 'maintenanceMode',
      title: 'Maintenance Mode',
      type: 'boolean',
      description: 'When enabled, all pages show the maintenance screen.',
      initialValue: false,
    },
    {
      name: 'newCheckoutEnabled',
      title: 'New Checkout Flow',
      type: 'boolean',
      description: 'Enables the redesigned checkout experience.',
      initialValue: false,
    },
    {
      name: 'pricingV2Enabled',
      title: 'Pricing Page V2',
      type: 'boolean',
      description: 'Shows the new pricing tiers to all visitors.',
      initialValue: false,
    },
  ],
}

The __experimental_actions restriction prevents editors from accidentally creating a second featureFlags document or deleting the singleton. This is a common pattern for config documents in Sanity.

Creating Singleton Documents with Fixed IDs

By default, Sanity generates a random UUID for every new document. For config singletons, you want a predictable, human-readable ID so your GROQ queries are stable. You can create a document with a fixed ID using the Sanity client:

javascript
// scripts/createSingletons.js
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID,
  dataset: process.env.SANITY_DATASET,
  token: process.env.SANITY_WRITE_TOKEN,
  apiVersion: '2024-01-01',
  useCdn: false,
})

const singletons = [
  { _id: 'siteSettings', _type: 'siteSettings' },
  { _id: 'featureFlags', _type: 'featureFlags' },
  { _id: 'themeConfig', _type: 'themeConfig' },
  { _id: 'announcementBanner', _type: 'announcementBanner' },
]

async function createSingletons() {
  for (const doc of singletons) {
    // createIfNotExists will not overwrite an existing document
    await client.createIfNotExists(doc)
    console.log(`Ensured singleton: ${doc._id}`)
  }
}

createSingletons()

Run this script once during project setup. After that, the documents exist with stable IDs and editors can update them freely through the Studio.

Querying Config in Next.js

Because config documents have fixed IDs, GROQ queries for them are simple and fast. Create a dedicated lib/config.js module that centralises all config fetching:

javascript
// lib/config.js
import { client } from './sanity'

export async function getSiteSettings() {
  return client.fetch(`*[_id == "siteSettings"][0]`)
}

export async function getFeatureFlags() {
  return client.fetch(`*[_id == "featureFlags"][0]`)
}

export async function getThemeConfig() {
  return client.fetch(`*[_id == "themeConfig"][0]`)
}

export async function getAnnouncementBanner() {
  return client.fetch(
    `*[_id == "announcementBanner" && active == true][0]{
      message,
      linkUrl,
      linkLabel,
      expiresAt
    }`
  )
}

In the App Router, you can fetch config in a root layout or in individual page components using React Server Components. Because these are server-side fetches, there is no risk of leaking sensitive config values to the client bundle:

javascript
// app/layout.js
import { getSiteSettings, getAnnouncementBanner } from '@/lib/config'
import AnnouncementBanner from '@/components/AnnouncementBanner'

export async function generateMetadata() {
  const settings = await getSiteSettings()
  return {
    title: { default: settings.siteName, template: `%s | ${settings.siteName}` },
    description: settings.defaultMetaDescription,
    openGraph: {
      siteName: settings.siteName,
    },
  }
}

export default async function RootLayout({ children }) {
  const [settings, banner] = await Promise.all([
    getSiteSettings(),
    getAnnouncementBanner(),
  ])

  return (
    <html lang="en">
      <body>
        {banner && <AnnouncementBanner data={banner} />}
        {children}
      </body>
    </html>
  )
}

Using Feature Flags to Gate Rendering

Feature flags from Sanity can gate entire pages, sections, or component variants. Because the flags are fetched server-side, the gating logic never reaches the browser — users simply receive the appropriate HTML:

javascript
// app/pricing/page.js
import { getFeatureFlags } from '@/lib/config'
import PricingV1 from '@/components/PricingV1'
import PricingV2 from '@/components/PricingV2'
import MaintenancePage from '@/components/MaintenancePage'

export default async function PricingPage() {
  const flags = await getFeatureFlags()

  if (flags.maintenanceMode) {
    return <MaintenancePage />
  }

  return flags.pricingV2Enabled ? <PricingV2 /> : <PricingV1 />
}

On-Demand Revalidation for Instant Config Updates

The real power of this pattern emerges when you combine it with Next.js on-demand revalidation. Instead of waiting for a time-based ISR interval, you configure a Sanity webhook to call a Next.js revalidation endpoint whenever a config document is published. This means an editor can flip a feature flag in the Studio and see the change live on the site within seconds — no deployment required.

javascript
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache'
import { parseBody } from 'next-sanity/webhook'

export async function POST(req) {
  try {
    const { isValidSignature, body } = await parseBody(
      req,
      process.env.SANITY_WEBHOOK_SECRET
    )

    if (!isValidSignature) {
      return new Response('Invalid signature', { status: 401 })
    }

    const configTypes = [
      'siteSettings',
      'featureFlags',
      'themeConfig',
      'announcementBanner',
    ]

    if (configTypes.includes(body._type)) {
      revalidateTag('sanity-config')
      return Response.json({ revalidated: true, type: body._type })
    }

    return Response.json({ revalidated: false })
  } catch (err) {
    return new Response(err.message, { status: 500 })
  }
}

Tag your config fetches so Next.js knows which cached data to invalidate:

javascript
// lib/config.js (with cache tags)
import { client } from './sanity'

export async function getFeatureFlags() {
  return client.fetch(
    `*[_id == "featureFlags"][0]`,
    {},
    { next: { tags: ['sanity-config'] } }
  )
}

Structuring the Studio for Config Editors

To make the config layer ergonomic for non-technical editors, organise the Sanity Studio so config documents are easy to find and hard to break. Use a dedicated "Settings" section in the Studio structure, and leverage Sanity's Structure Builder to surface each singleton directly as a top-level navigation item rather than a list view:

javascript
// sanity.config.js (structure customisation)
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'

const singletonTypes = new Set([
  'siteSettings',
  'featureFlags',
  'themeConfig',
  'announcementBanner',
])

export default defineConfig({
  // ...
  plugins: [
    structureTool({
      structure: (S) =>
        S.list()
          .title('Content')
          .items([
            S.listItem()
              .title('Settings')
              .child(
                S.list()
                  .title('Settings')
                  .items([
                    S.listItem()
                      .title('Site Settings')
                      .child(S.document().schemaType('siteSettings').documentId('siteSettings')),
                    S.listItem()
                      .title('Feature Flags')
                      .child(S.document().schemaType('featureFlags').documentId('featureFlags')),
                    S.listItem()
                      .title('Theme Config')
                      .child(S.document().schemaType('themeConfig').documentId('themeConfig')),
                    S.listItem()
                      .title('Announcement Banner')
                      .child(S.document().schemaType('announcementBanner').documentId('announcementBanner')),
                  ])
              ),
            // Divider before regular content types
            S.divider(),
            ...S.documentTypeListItems().filter(
              (item) => !singletonTypes.has(item.getId())
            ),
          ]),
    }),
  ],
})

Injecting Theme Tokens as CSS Custom Properties

A themeConfig document can drive your entire design token system. Fetch the theme at the root layout level and inject the values as CSS custom properties on the <html> element. This means a brand colour change in the Studio propagates to every component that references the token — no CSS file edits required:

javascript
// app/layout.js (theme injection)
import { getThemeConfig } from '@/lib/config'

export default async function RootLayout({ children }) {
  const theme = await getThemeConfig()

  const cssVars = {
    '--color-primary': theme.primaryColor?.hex ?? '#0070f3',
    '--color-secondary': theme.secondaryColor?.hex ?? '#ff4081',
    '--color-background': theme.backgroundColor?.hex ?? '#ffffff',
    '--font-heading': theme.headingFont ?? 'Inter, sans-serif',
    '--font-body': theme.bodyFont ?? 'Inter, sans-serif',
    '--border-radius': `${theme.borderRadius ?? 8}px`,
  }

  return (
    <html lang="en" style={cssVars}>
      <body>{children}</body>
    </html>
  )
}

Security Considerations

Because config documents are fetched server-side in React Server Components or getStaticProps, sensitive values (API keys, internal URLs, rate limits) never reach the client bundle. However, keep the following in mind:

  • Do not store secrets in Sanity config documents. Sanity documents are accessible via the API to anyone with a valid token. Use environment variables for secrets and Sanity for non-sensitive configuration.
  • Use Sanity's role-based access control to restrict which editors can modify high-impact config documents like featureFlags.
  • Validate config values defensively in your Next.js code. Always provide fallback defaults in case a config field is null or undefined.

Consider a SaaS marketing site built with Next.js and Sanity. The growth team wants to run a promotional campaign: show an announcement banner for one week, switch the hero CTA from "Start free trial" to "Get 3 months free", and enable a new pricing page variant. Traditionally, each of these changes would require a developer to update hardcoded strings, open a PR, and deploy. With a CMS config layer, the entire campaign can be set up in advance and activated by a non-developer at exactly the right moment.

Step 1: Define the Campaign Config Schema

javascript
// schemas/campaignConfig.js
export default {
  name: 'campaignConfig',
  title: 'Campaign Config',
  type: 'document',
  __experimental_actions: ['update', 'publish'],
  fields: [
    {
      name: 'heroCta',
      title: 'Hero CTA Label',
      type: 'string',
      initialValue: 'Start free trial',
    },
    {
      name: 'heroCtaUrl',
      title: 'Hero CTA URL',
      type: 'url',
      initialValue: '/signup',
    },
    {
      name: 'banner',
      title: 'Announcement Banner',
      type: 'object',
      fields: [
        { name: 'active', type: 'boolean', title: 'Show Banner', initialValue: false },
        { name: 'message', type: 'string', title: 'Message' },
        { name: 'linkLabel', type: 'string', title: 'Link Label' },
        { name: 'linkUrl', type: 'url', title: 'Link URL' },
        { name: 'expiresAt', type: 'datetime', title: 'Expires At' },
      ],
    },
    {
      name: 'pricingV2Enabled',
      title: 'Enable Pricing V2',
      type: 'boolean',
      initialValue: false,
    },
  ],
}

Step 2: Fetch and Apply Config in the App

javascript
// app/page.js (homepage)
import { client } from '@/lib/sanity'
import HeroSection from '@/components/HeroSection'
import AnnouncementBanner from '@/components/AnnouncementBanner'

async function getCampaignConfig() {
  return client.fetch(
    `*[_id == "campaignConfig"][0]`,
    {},
    { next: { tags: ['sanity-config'] } }
  )
}

export default async function HomePage() {
  const config = await getCampaignConfig()

  const now = new Date()
  const bannerActive =
    config.banner?.active &&
    (!config.banner.expiresAt || new Date(config.banner.expiresAt) > now)

  return (
    <main>
      {bannerActive && (
        <AnnouncementBanner
          message={config.banner.message}
          linkLabel={config.banner.linkLabel}
          linkUrl={config.banner.linkUrl}
        />
      )}
      <HeroSection
        ctaLabel={config.heroCta ?? 'Start free trial'}
        ctaUrl={config.heroCtaUrl ?? '/signup'}
      />
    </main>
  )
}

Step 3: Configure the Sanity Webhook

In the Sanity management dashboard (manage.sanity.io), create a webhook with the following settings:

  • URL: https://your-site.com/api/revalidate
  • Trigger on: Publish
  • Filter: _type in ["campaignConfig", "featureFlags", "siteSettings"]
  • Secret: a strong random string stored in SANITY_WEBHOOK_SECRET

Now the growth team can schedule the campaign entirely within the Studio. They set the banner message, expiry date, and CTA copy in advance, then publish the campaignConfig document at campaign launch time. The webhook fires, Next.js revalidates the tagged cache, and the new experience is live within seconds — no engineer involvement required.

Step 4: TypeScript Types for Config Safety

For larger teams, generate TypeScript types from your Sanity schema using sanity typegen. This ensures your Next.js components receive correctly-typed config values and TypeScript will catch any field name mismatches at compile time:

bash
# Generate types from your Sanity schema
npx sanity typegen generate

# This produces sanity.types.ts with types like:
# export type CampaignConfig = {
#   _id: string
#   _type: 'campaignConfig'
#   heroCta?: string
#   heroCtaUrl?: string
#   banner?: {
#     active?: boolean
#     message?: string
#     linkLabel?: string
#     linkUrl?: string
#     expiresAt?: string
#   }
#   pricingV2Enabled?: boolean
# }

"This is just a more complicated way to use environment variables"

Environment variables require a deployment to change. A CMS config layer does not. The fundamental difference is operational: env vars are developer-controlled and deployment-gated, while CMS config is editor-controlled and takes effect in seconds via on-demand revalidation. For any value that a non-developer might need to change — even occasionally — the CMS config layer is strictly superior.

"Singleton documents are an anti-pattern in Sanity"

Singletons are a well-established and officially supported pattern in Sanity. The Structure Builder API is specifically designed to surface singleton documents as first-class Studio items. The __experimental_actions restriction (or the newer document actions API) is the recommended way to prevent accidental duplication. Sanity's own documentation and starter templates use this pattern extensively.

"Config changes will cause a full site rebuild"

With Next.js on-demand revalidation and cache tags, only the pages that depend on the changed config document are revalidated. A featureFlags change does not rebuild your entire blog. You tag config fetches with a shared tag (e.g. sanity-config) and call revalidateTag() in your webhook handler. Next.js then re-renders only the affected pages on the next request — not the entire site.

"You should store API keys and secrets in Sanity config documents"

Never store secrets in Sanity. Sanity documents are accessible via the Content Lake API to any client with a valid read token, and in many setups the dataset is publicly readable. The CMS config layer is for non-sensitive operational configuration: feature flags, UI copy, theme tokens, and display settings. Secrets, API keys, and credentials belong in environment variables managed by your hosting platform.

"This pattern only works with static site generation"

The CMS config layer works equally well with SSR, ISR, and the App Router's React Server Components. In SSR mode, config is fetched on every request (consider a short-lived in-memory cache or Sanity's CDN to avoid latency). With ISR or the App Router's fetch cache, config is cached and revalidated on demand. The pattern is rendering-strategy agnostic — the key is that config is always fetched server-side, never bundled into client JavaScript.

"A dedicated feature flag service (LaunchDarkly, etc.) is always better"

Dedicated feature flag services offer capabilities that a CMS config layer does not: per-user targeting, percentage rollouts, real-time streaming updates, and detailed analytics. If you need those capabilities, use a dedicated service. However, for the majority of use cases — toggling a new page design, enabling a promotional banner, or switching a CTA — the CMS config layer is simpler, cheaper, and already integrated with your content workflow. It is the right tool for content-adjacent configuration; dedicated flag services are the right tool for complex targeting logic.