Toast
ContributorDecisions

ADR-008: Site Settings Architecture

ADR-008: Site Settings Architecture

Status

Accepted

Context

Toast needs site-level settings for configuration, branding, SEO, and locale. The approach to storing these settings has long-term implications for:

  1. Security - Some settings are public (branding, locale), others are sensitive (API keys, Stripe config)
  2. Performance - Public settings are read on every page load and should be edge-cacheable
  3. Type safety - Settings should be validated at the database and application level
  4. Extensibility - New settings will be added in Phase 3 (Stripe) and Phase 4 (email)

The Ghost Problem

Ghost uses a key-value settings table (key, value, type, group) that became a long-standing source of pain:

  • No type safety - everything is a string, requires serialization/deserialization
  • No database-level validation - invalid data can be written
  • Hard to query - can't do WHERE accent_color = '#ff5500'
  • AI-unfriendly - opaque key-value pairs vs clear schema
  • Security risk - no structural separation between public and private settings

Requirements

From the PRD Phase 1 scope:

  • Site identity: name, description
  • Branding: logo, icon, cover image, accent color
  • Locale: language, timezone
  • SEO/meta: meta title, meta description, og image

Future phases will add:

  • Phase 2: Navigation configuration
  • Phase 3: Stripe/membership configuration (sensitive keys)
  • Phase 4: Email sender configuration (provider credentials)

Research: What Other Projects Store

We analyzed Cal.com and Directus to understand what settings they store and how they categorize them.

Cal.com Settings (Booking Platform)

CategoryFieldsVisibility
Identityname, slug, bioPublic
Visual Brandinglogo, banner, brandColor, darkBrandColor, theme, hideBrandingPublic
Locale/TimetimeZone, weekStart, timeFormatPublic
SEOallowSEOIndexingPublic
Visibility ControlsisPrivate, hideTeamProfileLinkAdmin
Feature LockslockEventTypeCreation, isAdminAPIEnabledAdmin
Booking LimitsbookingLimits, includeManagedEventsInLimitsAdmin
Notification Controls9 different disableAttendee*Email flagsAdmin
Domain/VerificationorgAutoAcceptEmail, isOrganizationVerifiedAdmin
BillingsubscriptionId, paymentId, orgSeats (in metadata JSON)Admin

Directus Settings (Headless CMS)

CategoryFieldsVisibility
Project Identityproject_name, project_descriptor, project_urlPublic
Visual Brandingproject_logo, project_colorPublic
Login Page Assetspublic_foreground, public_background, public_faviconPublic
Themingdefault_appearance, theme_light_overrides, custom_cssPublic
Localizationdefault_languagePublic
Securityauth_login_attempts, auth_password_policyAdmin
Storagestorage_asset_transform, storage_asset_presetsAdmin
AI Integrationai_openai_api_key (masked), ai_anthropic_api_keyAdmin (sensitive)

Key Insight: Locale/Timezone Are Public

Both Cal.com and Directus expose locale and timezone publicly:

  • Cal.com shows timezone on booking pages for date formatting
  • Directus returns default_language in the unauthenticated /server/info endpoint

For Toast, themes need timezone for date formatting, and locale affects RSS feeds and meta tags. These belong in public settings.

What's Actually Private?

Private/admin-only settings fall into clear categories:

  • Security settings (login attempts, password policy)
  • API keys (Stripe, email providers, AI services)
  • Feature flags (lock features, admin API access)
  • Billing (subscription IDs, payment info)

For Toast Phase 1, all settings are public. Private settings arrive in Phase 3 (Stripe) and Phase 4 (email).

Options Considered

1. Key-Value Store (Ghost Pattern)

CREATE TABLE settings (
  key TEXT PRIMARY KEY,
  value TEXT,
  type TEXT,
  group TEXT
);

Pros:

  • Flexible - add new settings without migrations
  • Simple schema

Cons:

  • No type safety at DB level
  • Requires serialization/deserialization
  • Can't validate or query effectively
  • Security: public and private mixed together
  • Every project we researched has moved away from this pattern

2. Typed Columns on Sites Table

ALTER TABLE sites
ADD COLUMN description TEXT;

ALTER TABLE sites
ADD COLUMN logo TEXT;

-- ... 10+ more columns

Pros:

  • Simple - one table
  • Type safe

Cons:

  • Sites table grows unbounded
  • Mixes tenant identity with configuration
  • No separation between public/private settings
  • Every site query returns all settings

3. Single Settings Table (1:1 with Sites)

CREATE TABLE site_settings (
  site_id UUID PRIMARY KEY REFERENCES sites (id),
  description TEXT,
  logo TEXT,
  timezone TEXT,
  stripe_secret_key TEXT,
  -- all settings in one table
);

Pros:

  • Clean separation from sites table
  • Type safe
  • Single join for all settings

Cons:

  • No security boundary - public branding mixed with secret Stripe keys
  • Can't cache public settings separately from private
  • Table grows as features are added

4. Separate Public/Private Tables

CREATE TABLE site_settings_public (
  site_id UUID PRIMARY KEY REFERENCES sites (id),
  description TEXT,
  logo TEXT,
  timezone TEXT,
  locale VARCHAR(10),
  accent_color VARCHAR(7),
  meta_title VARCHAR(300),
  -- everything themes/public API need
);

CREATE TABLE site_settings_private (
  site_id UUID PRIMARY KEY REFERENCES sites (id),
  -- Phase 3: stripe_account_id, stripe_secret_key
  -- Phase 4: email_from_address, email_provider_config
  -- admin-only, may contain secrets
);

Pros:

  • Clear security boundary
  • Different caching strategies per table
  • Public settings exposed via Content API without risk
  • Each table focused on a specific concern
  • Type safe with database-level constraints

Cons:

  • Two tables instead of one
  • Two joins to get all settings (but usually you need only one)

Decision

Use separate public/private tables (Option 4):

  • site_settings_public - Everything themes and public API need (identity, branding, locale, SEO)
  • site_settings_private - Sensitive configuration (empty for Phase 1, ready for Stripe/email later)

Table Naming

We chose site_settings_public / site_settings_private over alternatives like site_branding because:

  1. "Branding" is too narrow - Public settings include identity, locale, and SEO, not just visual branding
  2. Clear 1:1 relationship - The site_ prefix makes it obvious these belong to sites
  3. Explicit visibility - public/private says exactly what the security boundary is
  4. Discoverable - In a table list, they appear together: sites, site_settings_public, site_settings_private

Rationale

  1. Security by structure - You cannot accidentally expose Stripe keys if they're in a different table
  2. Caching alignment - Public settings can be edge-cached; private settings should never be cached
  3. Access patterns differ - Every page load needs public settings; only admin needs private
  4. Future-proof - When Phase 3 adds Stripe and Phase 4 adds email, sensitive config has a clear home
  5. Industry consensus - Cal.com, Dub.co, Directus, and Payload all use typed fields, not KV stores

Research Summary

ProjectPatternKV Store?Observation
Cal.comDedicated 1:1 settings tablesNoOrganizationSettings (~20 typed columns), locale on main model
DirectusSingle-row typed columnsNo~50 columns, locale/branding exposed publicly via /server/info
Payload CMSConfig-driven GlobalsNoTyped fields map to typed columns
Dub.coTyped columns on workspaceNoJSON only for ephemeral UI state
StrapiKV store (core_store)YesOnly project using KV; documents the pain points

Implementation Details

Schema

/**
 * Public site settings - safe for themes, Content API, edge caching.
 *
 * Includes everything a theme or public consumer needs:
 * - Identity (description)
 * - Branding (logo, icon, colors)
 * - Locale (timezone, language)
 * - SEO defaults (meta tags, OG image)
 */
export const siteSettingsPublic = pgTable('site_settings_public', {
  siteId: uuid('site_id')
    .primaryKey()
    .references(() => sites.id, { onDelete: 'cascade' }),

  // Identity
  description: text('description'),

  // Branding
  logo: text('logo'),
  icon: text('icon'),
  coverImage: text('cover_image'),
  accentColor: varchar('accent_color', { length: 7 }),

  // Locale
  timezone: text('timezone').default('Etc/UTC').notNull(),
  locale: varchar('locale', { length: 10 }).default('en').notNull(),

  // SEO defaults
  metaTitle: varchar('meta_title', { length: 300 }),
  metaDescription: varchar('meta_description', { length: 500 }),
  ogImage: text('og_image'),

  updatedAt: timestamp('updated_at', { withTimezone: true })
    .defaultNow()
    .notNull()
    .$onUpdate(() => new Date()),
});

/**
 * Private site settings - admin only, may contain secrets.
 *
 * Empty for Phase 1. Will contain:
 * - Phase 3: Stripe configuration (account ID, secret key)
 * - Phase 4: Email configuration (sender, provider credentials)
 */
export const siteSettingsPrivate = pgTable('site_settings_private', {
  siteId: uuid('site_id')
    .primaryKey()
    .references(() => sites.id, { onDelete: 'cascade' }),

  // Phase 3: Membership/Stripe
  // stripeAccountId: text('stripe_account_id'),
  // stripeSecretKey: text('stripe_secret_key'),  // encrypt at rest

  // Phase 4: Email
  // emailFromAddress: text('email_from_address'),
  // emailReplyTo: text('email_reply_to'),
  // emailProvider: text('email_provider'),
  // emailProviderConfig: jsonb('email_provider_config'),

  updatedAt: timestamp('updated_at', { withTimezone: true })
    .defaultNow()
    .notNull()
    .$onUpdate(() => new Date()),
});

API Design

Public endpoint (edge-cacheable, no auth required):

GET /api/site
Response: { name, description, logo, icon, accentColor, timezone, locale, metaTitle, ... }
Cache-Control: public, max-age=60, stale-while-revalidate=300

Private endpoint (admin only):

GET /api/settings
Response: { public: {...}, private: {...} }
Cache-Control: private, no-store

PATCH /api/settings
Body: { public?: {...}, private?: {...} }

Row Creation Strategy

Settings rows are created when the site is created (in the same transaction), not lazily. This ensures:

  • No null checks needed in application code
  • Defaults are applied at creation time
  • Queries always return data
// In site creation service
await db.transaction(async (tx) => {
  const [site] = await tx.insert(sites).values({ name }).returning();
  await tx.insert(siteSettingsPublic).values({ siteId: site.id });
  await tx.insert(siteSettingsPrivate).values({ siteId: site.id });
  return site;
});

Adding New Settings

When adding a new setting:

  1. Determine visibility - Is it safe for public/themes, or admin-only/sensitive?
  2. Add typed column - Migration adds nullable column with default
  3. No backfill needed - Nullable columns don't require data migration
  4. Update validation - Add to Zod schema for API validation

This is more explicit than KV store but provides compile-time safety and database constraints.

Consequences

Positive

  • Type safety - Database schema, Drizzle types, and Zod validation all aligned
  • Security by design - Can't accidentally expose private config
  • Performance - Public settings edge-cacheable, private settings isolated
  • Queryable - Can filter/index on any setting
  • AI-friendly - Clear schema for LLM code generation

Negative

  • Migration required for new settings - Unlike KV store, adding a setting needs a migration
  • Two tables to manage - Though usually you're working with one at a time
  • Private table empty for Phase 1 - But it's ready for Phase 3-4

Trade-offs Accepted

The migration overhead for new settings is acceptable because:

  1. Settings are added rarely (a few per phase, not daily)
  2. Type safety catches errors at compile time, not runtime
  3. The security benefit of separation outweighs the convenience of KV

References

  • Issue #291: Epic: Site Settings & Configuration
  • PRD: docs/decisions/000-product-requirements.md
  • Cal.com: packages/prisma/schema.prisma (Team, OrganizationSettings models)
  • Directus: packages/types/src/settings.ts, api/src/database/seeds/
  • Payload CMS: Globals documentation
  • Dub.co: packages/prisma/schema.prisma (Project model)
  • Strapi: packages/core/core/src/services/core-store.ts

On this page