Superfunction

@spfn/cms

Type-safe Content Management System for Next.js with automatic database synchronization and published cache.

Features

  • 🎯 Type-safe labels - Full TypeScript support with autocomplete
  • 🔄 Auto-sync - Database synchronization on server startup
  • 🌐 Multi-language - Type-checked locale support
  • 🍪 Smart locale detection - Cookie-based with automatic fallback
  • 💾 Published cache - 17x faster queries (5ms vs 87ms)
  • 🎨 Nested structure - Organize labels hierarchically
  • 🔧 Template variables - Dynamic content with {placeholder} syntax
  • 📝 Draft & versioning - Version control and audit logs

Installation

pnpm spfn add @spfn/cms

Quick Start

1. Define Labels & Configuration

// labels.ts
import { defineLabelConfig, defineLabels, createCmsClient } from '@spfn/cms';
import { getLocale } from '@spfn/cms/actions';

// Configure locales
export const labelConfig = defineLabelConfig({
    locales: ['en', 'ko'] as const,
    defaultLocale: 'ko',
    fallbackLocale: 'en'
});

// Define labels with nested structure
export const labelsDefinition = defineLabels({
    home: {
        hero: {
            title: { en: "Welcome", ko: "환영합니다" },
            subtitle: { en: "Start your journey", ko: "여정을 시작하세요" },
            greeting: { en: "Hello {name}!", ko: "{name}님 안녕하세요!" }
        },
        cta: { en: "Get Started", ko: "시작하기" }
    },
    about: {
        title: { en: "About Us", ko: "회사 소개" }
    }
});

// Create client with API, getLabel, getLabels, and format
export const { api, getLabel, getLabels, format } = createCmsClient(
    labelsDefinition,
    {
        defaultLocale: labelConfig.defaultLocale,
        fallbackLocale: labelConfig.fallbackLocale,
        getLocale: () => getLocale(labelConfig.defaultLocale),
    }
);

2. Enable Auto-Sync

// server.config.ts
import { defineServerConfig } from '@spfn/core/server';
import { syncLabels } from '@spfn/cms/server';
import { labelsDefinition } from './labels';


// Option 1: Single definition
export default defineServerConfig()
    .lifecycle({
        afterInfrastructure: async () => {
            await syncLabels(labelsDefinition);
        }
    })
    .build();

// Option 2: Multiple definitions (organized in separate files)
import { homeLabels } from './labels/home';
import { aboutLabels } from './labels/about';

export default defineServerConfig()
    .lifecycle({
        afterInfrastructure: async () => {
            await syncLabels([homeLabels, aboutLabels]);
        }
    })
    .build();

3. Use in Your App

Server Component (Single Section):

import { getLabel, format } from '@/labels';

export default async function HomePage() {
    // Single section - direct access without section name
    const label = await getLabel('home');

    return (
        <div>
            <h1>{label.hero.title}</h1>
            <p>{label.hero.subtitle}</p>
            <button>{label.cta}</button>

            {/* Template variables */}
            <p>{format(label.hero.greeting, { name: 'John' })}</p>
            {/* Output: "John님 안녕하세요!" */}
        </div>
    );
}

Server Component (Multiple Sections):

import { getLabels } from '@/labels';

export default async function MultiSectionPage() {
    // Multiple sections - with section names as keys
    const labels = await getLabels(['home', 'about']);

    return (
        <div>
            <h1>{labels.home.hero.title}</h1>
            <p>{labels.about.title}</p>
        </div>
    );
}

Locale Management:

'use client';
import { setLocale } from '@spfn/cms/actions';

export function LanguageSwitcher() {
    return (
        <div>
            <button onClick={() => setLocale('ko')}>한국어</button>
            <button onClick={() => setLocale('en')}>English</button>
        </div>
    );
}

Key Features

🎯 Type Safety

Section Name Validation:

// ✅ Valid section names are enforced at compile time
const label = await getLabel('home'); // OK
await getLabel('homee'); // ❌ Compile error: 'homee' is not a valid section

// ✅ getLabel returns direct access (no section wrapper)
const homeLabel = await getLabel('home');
homeLabel.hero.title; // OK - direct access
homeLabel.cta; // OK

// ✅ getLabels requires array and returns sections with names
const labels = await getLabels(['home', 'about']);
labels.home.hero.title; // OK
labels.about.title; // OK
labels.contact.email; // ❌ Compile error: 'contact' was not requested

Property Access Validation:

// Single section - direct access
const label = await getLabel('signup');

// ✅ IDE autocomplete works perfectly
label.title; // OK - direct access
label.userName; // OK
label.email; // OK

// ❌ Typos are caught at compile time
label.titlee; // ❌ Compile error
label.userName; // ❌ Compile error (typo)

// Multiple sections - with section names
const labels = await getLabels(['home', 'about']);
labels.home.hero.title; // OK
labels.about.title; // OK

API Distinction:

// getLabel: Single section, direct access
const signup = await getLabel('signup');
signup.title; // ✅ Direct access (cleaner!)

// getLabels: Multiple sections, section names as keys
const multi = await getLabels(['home', 'signup']);
multi.home.title; // ✅ With section name
multi.signup.userName; // ✅ With section name

🎨 Nested Structure

defineLabels({
    features: {
        analytics: {
            title: { en: "Analytics", ko: "분석" },
            description: { en: "Track metrics", ko: "지표 추적" }
        },
        security: {
            title: { en: "Security", ko: "보안" }
        }
    }
});

// Single section - direct access with nesting
const label = await getLabel('features');
label.analytics.title; // "분석" (auto locale, direct access!)
label.analytics.description; // "지표 추적"
label.security.title; // "보안"

🔧 Template Variables

defineLabels({
    home: {
        welcome: {
            en: "Welcome {name}, you have {count} messages",
            ko: "{name}님, {count}개의 메시지가 있습니다"
        }
    }
});

const label = await getLabel('home');
const text = label.welcome; // Direct access!

format(text, { name: "John", count: 5 });
// Output: "John님, 5개의 메시지가 있습니다"

🍪 Smart Locale Detection

Automatic locale detection with priority:

  1. User's cookie (cms-locale)
  2. Config's defaultLocale
  3. Final fallback: 'en'
// User sets locale (saved to cookie)
await setLocale('ko');

// Automatically uses 'ko' locale
const label = await getLabel('home');
label.hero.title; // "환영합니다" (Korean)

// Switch to English
await setLocale('en');
const label2 = await getLabel('home');
label2.hero.title; // "Welcome" (English)

🔄 Auto-Sync

Labels synchronize automatically on server startup:

  • ✅ Creates new labels
  • ✅ Updates changed labels (deep equality check)
  • ✅ Skips unchanged labels (performance)
  • ✅ Rebuilds published cache
  • ⚠️ Optionally removes orphaned labels

API Reference

createCmsClient()

Factory function to create CMS client with API, getLabel, getLabels, and format utilities.

const { api, getLabel, getLabels, format } = createCmsClient(labelsDefinition, {
    defaultLocale: labelConfig.defaultLocale,
    fallbackLocale: labelConfig.fallbackLocale,
    getLocale: () => getLocale(labelConfig.defaultLocale),
});

Returns:

  • api - API client for CMS routes
  • getLabel(section) - Fetch a single section (direct access)
  • getLabels(sections) - Fetch multiple sections (with section names)
  • format(template, vars) - Template variable substitution

getLabel()

Fetch a single section from published cache with direct access (no section name wrapper).

// Single section - direct access
const label = await getLabel('home');
label.hero.title; // Direct access without 'home.' prefix
label.cta;

Use when:

  • You need labels from only ONE section
  • You want cleaner, direct access to properties
  • Most common use case for individual pages

Returns: Labels directly without section name wrapper

Features:

  • Auto locale detection (cookie → defaultLocale → 'en')
  • Direct property access (no section name)
  • Type-safe with IDE autocomplete
  • Merges published cache with defaults

getLabels()

Fetch multiple sections from published cache with section names as keys.

// Multiple sections - with section names
const labels = await getLabels(['home', 'about']);
labels.home.hero.title; // Access via section name
labels.about.title;

Use when:

  • You need labels from MULTIPLE sections
  • You're building a page that uses multiple label groups

Returns: Object with section names as keys

Features:

  • Auto locale detection (cookie → defaultLocale → 'en')
  • Section filtering (only processes requested sections)
  • Type-safe: only requested sections available
  • Merges published cache with defaults

Performance:

  • Only requested sections are processed (not entire labelsDefinition)
  • 10x faster when requesting specific sections
  • Reduces CPU and memory usage proportionally

format()

Replace template variables in strings.

format("Hello {name}!", { name: "John" }); // "Hello John!"
format("{count} items", { count: 5 }); // "5 items"

Syntax: {variableName} - Supports strings and numbers

setLocale() / getLocale()

Server actions for locale management (cookie-based).

// Set user's preferred locale
await setLocale('ko'); // Saves to 'cms-locale' cookie

// Get current locale
const locale = await getLocale(defaultLocale); // Returns: cookie → defaultLocale → 'en'

Cookie settings:

  • Name: cms-locale
  • Max age: 1 year
  • HttpOnly, Secure (production), SameSite: lax

syncLabels()

Synchronize labels to database (server-side only).

await syncLabels(labelsDefinition, {
    removeOrphaned: false, // Delete labels not in code
    dryRun: false // Preview changes without applying
});

Returns: { added, updated, removed, unchanged }

Admin API

CMS 라벨을 관리하기 위한 Admin API를 제공합니다. 섹션별 테이블 뷰로 라벨을 조회/수정/발행할 수 있습니다.

Admin Routes

cmsAppRouter에 포함된 Admin 라우트들:

Route Method Description
getSectionLabels GET 섹션의 모든 라벨 조회 (Draft/Published 상태 포함)
saveSectionDraft PUT 섹션 라벨 일괄 Draft 저장
publishSection POST 섹션 전체 발행 (Draft → Published)
resetSectionDraft DELETE 섹션 Draft 초기화

Admin UI 구현 예제

1. API Client 설정:

// labels.ts
import { createCmsClient } from '@spfn/cms';

export const { api: cmsApi } = createCmsClient(labelsDefinition, {
    defaultLocale: labelConfig.defaultLocale,
    fallbackLocale: labelConfig.fallbackLocale,
    getLocale: () => getLocale(labelConfig.defaultLocale),
});

2. 섹션 라벨 조회:

// 섹션의 모든 라벨을 Draft/Published 상태와 함께 조회
const data = await cmsApi.getSectionLabels.call({
    params: { section: 'home' },
    query: { locales: 'en,ko' },  // 콤마로 구분
});

// 반환값
{
    section: 'home',
    locales: ['en', 'ko'],
    labels: [
        {
            id: 1,
            key: 'home.hero.title',
            defaultValue: { en: 'Welcome', ko: '환영합니다' },
            draft: { en: 'Welcome!', ko: '환영합니다!' } | null,
            published: { en: 'Welcome', ko: '환영합니다' } | null,
            hasDraft: true
        },
        // ...
    ]
}

3. Draft 저장:

// 수정된 라벨들을 Draft로 저장
await cmsApi.saveSectionDraft.call({
    params: { section: 'home' },
    body: {
        labels: [
            { id: 1, values: { en: 'Welcome!', ko: '환영합니다!' } },
            { id: 2, values: { ko: '새로운 부제목' } },  // 특정 locale만 수정 가능
        ]
    },
});

4. 섹션 발행:

// Draft가 있는 모든 라벨을 Published로 발행
const result = await cmsApi.publishSection.call({
    params: { section: 'home' },
    body: { locales: ['en', 'ko'] },
});

// 반환값
{
    published: 2,           // 발행된 라벨 수
    version: 3,             // 최대 버전 번호
    labels: ['home.hero.title', 'home.hero.subtitle']  // 발행된 라벨 키
}

5. Draft 초기화:

// 섹션의 모든 Draft 삭제 (Published 값으로 복원)
await cmsApi.resetSectionDraft.call({
    params: { section: 'home' },
});

Workflow

┌─────────────┐     saveSectionDraft     ┌─────────────┐
│  Default    │ ──────────────────────►  │    Draft    │
│  (코드 정의)  │                          │ (version:null)│
└─────────────┘                          └──────┬──────┘
                                                │
                    publishSection              │
                ◄───────────────────────────────┘
                                                │
                                                ▼
                                         ┌─────────────┐
                                         │  Published  │
                                         │ (version:N) │
                                         └─────────────┘

상태 우선순위: Draft > Published > Default

상태 표시

상태 의미 UI 표시 예
Default 코드에 정의된 기본값만 존재 회색
Draft 저장되었으나 미발행 노란색
Published 발행되어 실제 서비스에 반영 초록색
Edited UI에서 수정 중 (미저장) 파란색

Architecture

Database Schema

cms_labels (metadata)
  ├─ id, key, section, type, defaultValue
  └─ publishedVersion

cms_label_values (actual content)
  ├─ labelId, version, locale, breakpoint
  └─ value (JSONB)

cms_published_cache (performance)
  ├─ section, locale, content (JSONB)
  └─ version (for cache invalidation)

Query Flow

  1. getLabels() → published_cache (single query, 5ms)
  2. Fallback → bindLocale(defaults)
  3. Merge → cache overrides defaults
  4. Return → type-safe nested object

Performance

  • Published cache: 5ms (vs 87ms with JOINs) - 17x faster
  • N+1 prevention: Bulk section queries with inArray()
  • Section filtering: Only requested sections processed (10x faster for selective access)
  • Unchanged labels: Skipped during sync (deep equality check)
  • Client caching: Version-based invalidation

Example: Large Scale

// 10 sections with 100 labels each = 1,000 total labels
const labelsDefinition = {
    home: { /* 100 labels */ },
    about: { /* 100 labels */ },
    products: { /* 100 labels */ },
    // ... 7 more sections
};

// Only request 'home' - direct access
const label = await getLabel('home');

// ✅ Performance: Processes only 100 labels (10% of total)
// ❌ Without filtering: Would process all 1,000 labels

Benefits:

  • CPU usage: 10x reduction (100 vs 1,000 labels)
  • Memory usage: 10x reduction
  • Response time: Proportionally faster

Development Status

This package is currently in alpha. APIs may change.

License

MIT