Headless Commerce February 28, 2026 11 min read

The Config-Driven Storefront: One File, Complete Control

Why we moved every storefront decision into a single TypeScript configuration file. And why it is better than a CMS, a database, or Shopify's theme editor.

Tyler Colby · Founder, Colby's Data Movers

When we started building headless Shopify storefronts, the configuration was scattered. Brand colors in a CSS file. Navigation links in a layout component. Shipping policies in a markdown file. AI assistant personality in a prompt template. Feature flags in environment variables. The store name in four different places.

Every new client deployment meant a scavenger hunt through 30 files to find and replace hardcoded values. We missed things. A store launched with another client's phone number in the footer. That was the breaking point.

We moved everything into one file. One TypeScript file. Every decision about brand, content, features, and behavior lives in a single typed configuration object. Here is why, and here is the complete type definition.

The Problem with Alternatives

Before explaining why we chose a config file, let me explain why we rejected the obvious alternatives.

Why Not a CMS?

A headless CMS like Contentful, Sanity, or Strapi seems like the right answer. Put your content in a CMS. Let non-developers edit it. Standard practice.

But for a storefront, most of the "configuration" is not content. It is structural. Navigation links determine which pages exist. Feature flags determine which components render. The AI system prompt determines the assistant's behavior. A CMS handles paragraphs and images well. It handles feature flags and TypeScript types poorly.

A CMS also introduces a runtime dependency. If Contentful goes down, your storefront cannot render its navigation. With a config file, the configuration is baked into the build. No external dependency at runtime.

The final issue: cost. A CMS adds $99-399/month for a problem that a TypeScript file solves for free.

Why Not a Database?

Same problem, different flavor. A database requires a server, a connection, an ORM or query layer, and a migration strategy. For what? To store 200 lines of configuration that changes once a month? The operational overhead is not justified. A file in the repository is versioned by git, reviewed in pull requests, and deployed atomically with the code that consumes it.

Why Not Shopify's Theme Editor?

Because we left Shopify's theme system to build a headless storefront in the first place. Going back to Shopify's settings schema means going back to Liquid templates, Shopify's JavaScript framework, and the 40-point Lighthouse score that made us leave.

Why Not Environment Variables?

Environment variables work for secrets (API keys, tokens) and deployment targets (staging vs production). They do not work for structured data. You cannot put a navigation array, a shipping policy with HTML, or an AI system prompt into an environment variable. Well, you can. But you should not.

The CartridgeConfig Type

Here is the complete TypeScript type that defines every configurable aspect of the storefront:

// cartridge.config.ts
export interface CartridgeConfig {
  // Store identity
  store: {
    name: string;
    tagline: string;
    domain: string;
    shopifyDomain: string;
    logo?: string;
    favicon?: string;
  };

  // Brand colors and typography
  brand: {
    primaryColor: string;
    accentColor: string;
    backgroundColor: string;
    textColor: string;
    fontHeading: string;
    fontBody: string;
    borderRadius: 'none' | 'sm' | 'md' | 'lg' | 'full';
  };

  // Navigation structure
  nav: {
    links: Array<{
      label: string;
      href: string;
      children?: Array<{ label: string; href: string }>;
    }>;
    cta: { label: string; href: string };
  };

  // Homepage sections
  hero: {
    headline: string;
    subheadline: string;
    cta: { label: string; href: string };
    backgroundImage?: string;
    featuredProducts?: string[]; // product handles
  };

  // About page content
  about: {
    headline: string;
    story: string; // supports HTML
    founder?: {
      name: string;
      title: string;
      image?: string;
      bio: string;
    };
    values?: Array<{ title: string; description: string }>;
  };

  // AI Sales Assistant
  ai: {
    enabled: boolean;
    provider: 'anthropic' | 'openai';
    model: string;
    personality: string;
    systemPromptTemplate: string;
    greeting: string;
    suggestedQuestions: string[];
    tools: {
      searchProducts: boolean;
      createQuote: boolean;
      captureEmail: boolean;
    };
    rateLimit: {
      maxRequestsPerMinute: number;
      maxRequestsPerSession: number;
    };
  };

  // Embed widget configuration
  embed: {
    enabled: boolean;
    allowedDomains: string[];
    position: 'bottom-right' | 'bottom-left';
    theme: 'light' | 'dark' | 'auto';
  };

  // Shipping and policies
  shipping: {
    freeShippingThreshold?: number;
    methods: Array<{
      name: string;
      description: string;
      estimatedDays: string;
      price?: number;
    }>;
    returnPolicy: string; // supports HTML
  };

  // Feature flags
  features: {
    search: boolean;
    wishlist: boolean;
    quickView: boolean;
    imageZoom: boolean;
    emailPopup: boolean;
    stickyMobileCta: boolean;
    collectionFilters: boolean;
    availabilityBadges: boolean;
    socialSharing: boolean;
    productRecommendations: boolean;
    reviews: boolean;
  };

  // Custom pages
  pages?: Array<{
    slug: string;
    title: string;
    content: string; // HTML
    showInNav: boolean;
  }>;

  // Custom forms (contact, wholesale inquiry, etc.)
  forms?: Array<{
    id: string;
    title: string;
    fields: Array<{
      name: string;
      type: 'text' | 'email' | 'textarea' | 'select' | 'phone';
      label: string;
      required: boolean;
      options?: string[];
    }>;
    submitTo: string; // API endpoint or email
    successMessage: string;
  }>;

  // SEO defaults
  seo: {
    titleTemplate: string; // e.g., "%s | Store Name"
    defaultDescription: string;
    ogImage?: string;
    twitterHandle?: string;
  };

  // Analytics
  analytics?: {
    googleAnalytics?: string;
    facebookPixel?: string;
    customScripts?: string[];
  };
}

That is about 120 lines of type definition. A concrete configuration file is 200-400 lines depending on how much content the merchant provides. Every field is typed. Every field has a clear purpose. The IDE gives you autocomplete, type checking, and inline documentation.

What This Controls

Let me walk through what happens when you change each section.

Change store.name and it updates the header, footer, SEO titles, Open Graph tags, structured data, email templates, and the AI assistant's self-identification. One value, seven locations.

Change brand.primaryColor and it updates CSS custom properties that cascade through every button, link, accent, and highlight in the storefront. The AI chat widget picks it up too.

Change features.wishlist from true to false and the heart icon disappears from every product card, the wishlist page returns a 404, and the localStorage key is never written.

Change ai.personality and the AI assistant's tone shifts. Set it to "professional and concise" for B2B. Set it to "friendly and enthusiastic" for DTC. Set it to "expert and authoritative" for luxury goods. The system prompt template interpolates this value alongside the full product catalog.

How Components Consume Config

The config is loaded once at build time and made available through a simple import:

// lib/config.ts
import config from '@/cartridge.config';
export default config;

// In any component:
import config from '@/lib/config';

function Header() {
  return (
    <header>
      <a href="/">{config.store.name}</a>
      <nav>
        {config.nav.links.map(link => (
          <a key={link.href} href={link.href}>{link.label}</a>
        ))}
      </nav>
      <a href={config.nav.cta.href} className="btn">
        {config.nav.cta.label}
      </a>
    </header>
  );
}

No context provider. No hook. No async fetch. Just an import. The config is statically analyzable. The bundler tree-shakes unused sections. If you disable the AI assistant, the AI code is not shipped to the browser.

Feature flags gate rendering at the component level:

function ProductCard({ product }: { product: Product }) {
  return (
    <div className="product-card">
      <ProductImage product={product} />
      <ProductInfo product={product} />
      {config.features.wishlist && (
        <WishlistButton productId={product.id} />
      )}
      {config.features.quickView && (
        <QuickViewButton product={product} />
      )}
      {config.features.availabilityBadges && (
        <AvailabilityBadge variant={product.variants[0]} />
      )}
    </div>
  );
}

This is not clever. It is obvious. That is the point. Any developer can open the config file, change a value, and predict exactly what will happen. No documentation needed. The types are the documentation.

The Brand System

The brand section of the config generates CSS custom properties at build time:

// lib/theme.ts
import config from '@/lib/config';

export function generateCSSVariables(): string {
  const { brand } = config;
  return `
    :root {
      --color-primary: ${brand.primaryColor};
      --color-accent: ${brand.accentColor};
      --color-bg: ${brand.backgroundColor};
      --color-text: ${brand.textColor};
      --font-heading: '${brand.fontHeading}', sans-serif;
      --font-body: '${brand.fontBody}', sans-serif;
      --radius: var(--radius-${brand.borderRadius});
    }
  `;
}

Every component uses these variables. Change the primary color and the entire storefront updates. No theme file to edit. No CSS to write. Five characters in the config file.

The AI System Prompt

The AI assistant configuration is worth examining in detail because it shows how a config value can be both simple to set and powerful in effect.

ai: {
  enabled: true,
  provider: 'anthropic',
  model: 'claude-sonnet-4-20250514',
  personality: 'knowledgeable and direct, like a friend who happens to be an expert',
  systemPromptTemplate: `You are the shopping assistant for {{store.name}}.
Your personality: {{ai.personality}}.

{{store.name}} sells: {{catalog_summary}}.

Available products:
{{product_catalog}}

Rules:
- Only recommend products that exist in the catalog above
- Always include prices when recommending products
- If asked about something we don't sell, say so honestly
- Never make up product details`,
  greeting: 'Hey! Looking for something specific, or want me to help you find the right gear?',
  suggestedQuestions: [
    'What leather bags do you have?',
    'I need a gift under $100',
    'What is your best seller?'
  ],
  tools: {
    searchProducts: true,
    createQuote: true,
    captureEmail: true
  },
  rateLimit: {
    maxRequestsPerMinute: 10,
    maxRequestsPerSession: 50
  }
}

The systemPromptTemplate uses double-brace interpolation. At runtime, {{store.name}} is replaced with the store name from the config. {{product_catalog}} is replaced with the full product catalog fetched from Shopify. The merchant writes one template. The system fills in the data.

The tools object controls which AI capabilities are active. Disable createQuote and the AI cannot generate quote requests. Disable captureEmail and it will not ask for email addresses. Each tool is a function the AI can call, and the config determines which functions are available.

The Trade-Off

I said honest trade-offs, so here they are.

Non-developers cannot edit the config. A marketing manager cannot open a TypeScript file and change the hero headline. They need a developer, or they need a UI that wraps the config file. We are building that UI (more on that later), but the config file is the source of truth. The UI writes to it.

Complex layouts are not configurable. The config controls which sections appear, what content they contain, and how they are styled. It does not control the layout of individual sections. If you want a three-column feature grid instead of a two-column one, you edit a component. The config is for content and behavior, not for layout.

Some content is HTML strings. The about page story, the return policy, custom page content. These are HTML strings in the config file. It works, but it is not pleasant to edit long HTML inside a TypeScript string literal. For longer content, we support importing from separate HTML files:

import { readFileSync } from 'fs';

const config: CartridgeConfig = {
  // ...
  about: {
    headline: 'Our Story',
    story: readFileSync('./content/about.html', 'utf-8'),
  },
  shipping: {
    returnPolicy: readFileSync('./content/returns.html', 'utf-8'),
  },
};

Adding new configurable features requires code changes. The config type is part of the codebase. Adding a new feature flag means adding a field to the type, implementing the feature in a component, and gating it behind the new flag. This is deliberate. Every configurable option is explicitly supported, tested, and documented by its type definition.

Validation

The config is validated at build time with Zod. If you set brand.primaryColor to "banana" instead of a hex code, the build fails with a clear error message. If you enable the AI assistant but forget to set the model, the build fails. If a navigation link points to a page that does not exist in the pages array, the build warns.

// lib/validate-config.ts
import { z } from 'zod';

const configSchema = z.object({
  store: z.object({
    name: z.string().min(1),
    domain: z.string().url(),
    shopifyDomain: z.string().regex(/\.myshopify\.com$/),
  }),
  brand: z.object({
    primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
    // ...
  }),
  ai: z.object({
    enabled: z.boolean(),
    model: z.string().min(1).optional(),
  }).refine(
    data => !data.enabled || data.model,
    { message: 'AI model is required when AI is enabled' }
  ),
});

Catching configuration errors at build time instead of runtime is the entire point. A CMS lets you publish broken configuration to a live site. A validated config file does not.

One File, Complete Control

The config-driven architecture is not innovative. It is boring. That is why it works. Every value has a clear location. Every change has a predictable effect. Every error is caught before deployment.

For the class of problem we are solving, which is deploying custom-branded Shopify storefronts with a consistent feature set, a single config file is the right abstraction. It is less flexible than a CMS. It is less dynamic than a database. It is less visual than a theme editor. But it is more predictable than all of them. And for a storefront that handles real money, predictability is the feature that matters most.

Next up: the specific features this config enables and why Shopify templates cannot replicate them.