Skip to main content

Content Management Deep Dive

This guide covers the full content management lifecycle in the Ever Works Template, from how content is stored and parsed to caching, synchronization, and multi-language translation support.

Content Data Model

All content in the template is driven by YAML files stored in a Git repository. Each content item lives in its own directory under .content/data/ and follows a consistent schema defined in lib/content.ts.

Item Schema

// lib/content.ts
export interface ItemData {
name: string;
slug: string;
description: string;
source_url: string;
category: string | Category | Category[] | string[];
tags: string[] | Tag[];
collections?: string[] | Collection[];
featured?: boolean;
icon_url?: string;
updated_at: string; // Raw string timestamp (e.g. "2024-06-15 10:30")
updatedAt: Date; // Parsed Date object
promo_code?: PromoCode;
markdown?: string;
is_source_url_active?: boolean;
action?: 'visit-website' | 'start-survey' | 'buy';
publisher?: string;
location?: ItemLocationData;
}

A typical YAML item file looks like this:

# .content/data/my-tool/my-tool.yml
name: "My Tool"
description: "A great productivity tool"
source_url: "https://mytool.example.com"
category: "productivity"
tags:
- time-tracking
- collaboration
featured: true
updated_at: "2024-06-15 10:30"

Supporting Types

Categories, tags, and collections each have their own top-level YAML files:

// lib/content.ts
export interface Category {
id: string;
name: string;
icon_url?: string;
count?: number;
image_url?: string;
}

export interface Tag {
id: string;
name: string;
count?: number;
}

These are stored in .content/categories/categories.yml, .content/tags/tags.yml, and .content/collections/collections.yml.

Git-Based CMS Architecture

The template uses a Git-based CMS approach. Content is stored in a separate GitHub repository, referenced by the DATA_REPOSITORY environment variable. This provides version control, collaboration via pull requests, and a clear audit trail.

Content Path Resolution

The path to content varies by environment. The getContentPath() function in lib/lib.ts handles this:

// lib/lib.ts
export function getContentPath() {
const contentDir = '.content';
const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build';

// Vercel runtime: use /tmp because build artifact is read-only
if (process.env.VERCEL && !isBuildPhase) {
return path.join(os.tmpdir(), contentDir); // /tmp/.content
}

// Local dev, Docker, Kubernetes: use project directory
return path.join(process.cwd(), contentDir); // ./.content
}
EnvironmentContent PathWritablePersistent
Local development./.contentYesYes
Vercel build./.contentYesBuild only
Vercel runtime/tmp/.contentYesNo (ephemeral)
Docker / K8s./.content or mounted volumeYesDepends on config

Initial Clone

During development, the content repository is cloned by scripts/clone.cjs:

// scripts/clone.cjs (simplified)
const git = require("isomorphic-git");
const http = require("isomorphic-git/http/node");

const url = process.env.DATA_REPOSITORY;
const dest = path.join(process.cwd(), '.content');

await git.clone({
fs,
http,
url,
dir: dest,
singleBranch: true,
onAuth: () => ({ username: "x-access-token", password: token })
});

Lazy Initialization at Runtime

On cold starts (especially Vercel serverless functions), content is cloned on first access via ensureContentAvailable():

// lib/lib.ts
export async function ensureContentAvailable(): Promise<string> {
const state = getContentInitState();

if (state.initialized) {
return getContentPath();
}

if (state.promise) {
return state.promise;
}

state.promise = (async () => {
const contentPath = getContentPath();
await fs.mkdir(contentPath, { recursive: true });

const hasContent = await hasContentFiles(contentPath);

if (!hasContent) {
// Clone from Git on first request to cold container
const { trySyncRepository } = await import('./repository');
await trySyncRepository();
}

state.initialized = true;
return contentPath;
})();

return state.promise;
}

This uses a globalThis singleton to ensure the initialization happens only once per serverless container.

Content Parsing

Items are parsed from YAML using the parseItem function, which includes security measures to prevent directory traversal:

// lib/content.ts
async function parseItem(base: string, filename: string) {
const sanitizedFilename = sanitizeFilename(filename);
const filepath = path.join(base, sanitizedFilename);
const content = await safeReadFile(filepath, base);
const meta = yaml.parse(content) as ItemData;
meta.slug = path.basename(sanitizedFilename, path.extname(sanitizedFilename));
meta.updatedAt = parse(meta.updated_at, 'yyyy-MM-dd HH:mm', new Date());
return meta;
}

Security Utilities

The content layer includes built-in security functions to prevent path traversal and injection attacks:

// lib/content.ts
function sanitizeFilename(filename: string): string {
const sanitized = path.basename(filename);
if (sanitized.includes('..') || sanitized.includes('/') || sanitized.includes('\\')) {
throw new Error('Invalid filename: contains dangerous characters');
}
return sanitized;
}

function validatePath(filepath: string, basePath: string): void {
const resolvedPath = path.resolve(filepath);
const resolvedBase = path.resolve(basePath);
if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
throw new Error('Invalid file path: outside of allowed directory');
}
}

Fetching and Populating Content

The main entry point for reading content is fetchItems(), which reads all items from the filesystem, populates category/tag references, and applies sorting:

// lib/content.ts (simplified)
export async function fetchItems(options: FetchOptions = {}): Promise<FetchItemsResult> {
await ensureContentAvailable();

const dest = path.join(getContentPath(), 'data');
const files = await fsp.readdir(dest);

const categories = await readCategories(options);
const tags = await readTags(options);
const collections = await readCollections(options);

const items = await Promise.all(
files.map(async (slug) => {
const item = await parseItem(path.join(dest, slug), `${slug}.yml`);

// Populate tag and category references
if (Array.isArray(item.tags)) {
item.tags = item.tags.map((tag) => populateTag(tag, tags));
}
if (Array.isArray(item.category)) {
item.category = item.category.map((cat) => populateCategory(cat, categories));
}
return item;
})
);

return {
total: items.length,
items: items.sort((a, b) => {
if (a.featured && !b.featured) return -1;
if (!a.featured && b.featured) return 1;
return b.updatedAt.getTime() - a.updatedAt.getTime();
}),
categories: Array.from(categories.values()),
tags: Array.from(tags.values()),
collections: Array.from(collections.values()),
};
}

Multi-Layer Caching

The content system uses multiple caching layers to minimize filesystem reads and improve performance.

Layer 1: In-Memory Cache

// lib/content.ts
const fetchItemsCache = new Map<string, { data: FetchItemsResult; timestamp: number }>();
const FETCH_ITEMS_CACHE_TTL = 600000; // 10 minutes

// Smart directory caching avoids re-reading the filesystem
const directoryCache = new Map<string, DirectoryCache>();
const DIRECTORY_CACHE_TTL = 600000; // 10 minutes

The directory cache is especially smart -- it checks the directory modification time (mtime) and only re-reads from disk if the directory has actually changed:

const dirStat = await fsp.stat(dest);
const currentMtime = dirStat.mtimeMs;

if (cachedDir && cachedDir.mtime === currentMtime &&
Date.now() - cachedDir.timestamp < DIRECTORY_CACHE_TTL) {
// Use cached data - directory hasn't changed
files = cachedDir.files;
} else {
// Re-read from filesystem
files = await fsp.readdir(dest);
}

Layer 2: Next.js unstable_cache

Configuration data is cached with Next.js built-in caching:

// lib/content.ts
export const getCachedConfig = unstable_cache(
async () => {
return await getConfig();
},
['config'],
{ revalidate: 60 }
);

Layer 3: Cache Tags and Invalidation

Cache tags are defined centrally in lib/cache-config.ts:

// lib/cache-config.ts
export const CACHE_TAGS = {
CONTENT: 'content',
ITEMS: 'items',
ITEM: (slug: string) => `item:${slug}`,
CATEGORIES: 'categories',
TAGS: 'tags',
COLLECTIONS: 'collections',
CONFIG: 'config',
PAGES: 'pages',
ITEMS_LOCALE: (locale: string) => `items:${locale}`,
};

export const CACHE_TTL = {
CONTENT: 600, // 10 minutes
ITEM: 600,
CONFIG: 600,
PAGES: 600,
};

Cache Invalidation

After a sync operation, all caches are invalidated through lib/cache-invalidation.ts:

// lib/cache-invalidation.ts
export async function invalidateContentCaches(): Promise<void> {
safeRevalidateTag(CACHE_TAGS.CONTENT);
safeRevalidateTag(CACHE_TAGS.ITEMS);
safeRevalidateTag(CACHE_TAGS.CATEGORIES);
safeRevalidateTag(CACHE_TAGS.TAGS);
safeRevalidateTag(CACHE_TAGS.COLLECTIONS);
safeRevalidateTag(CACHE_TAGS.PAGES);

// Also clear in-memory caches
await clearFetchItemsCache();
}

The safeRevalidateTag wrapper handles the case where revalidation is called during a React render phase:

function safeRevalidateTag(tag: string): void {
try {
revalidateTag(tag, 'max');
} catch (error) {
if (error instanceof Error && isRenderPhaseError(error)) {
console.warn(`Skipping cache invalidation during render phase (tag: ${tag})`);
} else {
throw error;
}
}
}

Content Synchronization

Content is kept up to date through periodic Git sync operations managed by lib/repository.ts.

Pull Mechanism

The repository module uses isomorphic-git (a pure JavaScript Git implementation) to pull changes:

// lib/repository.ts (simplified)
export async function trySyncRepository() {
const url = process.env.DATA_REPOSITORY;
const dest = getContentPath();
const auth = getGitAuth(process.env.GH_TOKEN);

const gitDir = path.join(dest, '.git');
if (await fsExists(gitDir)) {
// Repository exists - pull changes
await pullChanges(url, dest, auth);
} else {
// Fresh clone
await git.clone({ fs, http, url, dir: dest, singleBranch: true, onAuth: () => auth });
}

// Invalidate caches after sync
await invalidateContentCaches();
}

Handling Local Changes

Before pulling remote changes, the system checks for uncommitted local changes (from admin write operations) and pushes them first:

// lib/repository.ts
async function checkForLocalChanges(dir: string): Promise<boolean> {
const status = await git.statusMatrix({ fs, dir });
return status.some(([, head, workdir, stage]) =>
head !== workdir || head !== stage
);
}

async function tryPushLocalChanges(dir: string, url: string, auth: GitAuth): Promise<boolean> {
await git.add({ fs, dir, filepath: '.' });
await git.commit({
fs, dir,
message: `[Auto] Save local changes before sync - ${new Date().toISOString()}`,
author: { name: 'Website Bot', email: 'bot@ever.works' },
});
await git.push({ onAuth: () => auth, fs, http, dir, url });
return true;
}

Multi-Language Content

The content system supports translations through language-specific YAML files placed alongside the base content.

Translation File Structure

.content/
data/
my-tool/
my-tool.yml # Base content (English)
my-tool.fr.yml # French translation
my-tool.es.yml # Spanish translation
my-tool.ar.yml # Arabic translation
categories/
categories.yml # Base categories
categories.fr.yml # French category translations
tags/
tags.yml
tags.fr.yml

Translation Loading

Translations are loaded and merged with the base content:

// lib/content.ts
if (options.lang && options.lang !== 'en') {
if (!validateLanguageCode(options.lang)) {
throw new Error(`Invalid language code: ${options.lang}`);
}
const translation = await parseTranslation(base, `${slug}.${options.lang}.yml`);
if (translation) Object.assign(item, translation);
}

Language codes are validated to prevent path traversal attacks:

function validateLanguageCode(lang: string): boolean {
const validLangPattern = /^[a-zA-Z0-9_-]+$/;
return validLangPattern.test(lang) && lang.length <= 10;
}

Configuration via YAML

The global site configuration is stored in .content/.works/works.yml and parsed into a typed Config interface:

// lib/content.ts
export interface Config {
company_name?: string;
content_table?: boolean;
item_name?: string;
items_name?: string;
app_url?: string;
auth?: false | AuthOptions;
authConfig?: AuthConfig;
pricing?: PricingPlanConfig;
pagination?: TypePagination;
settings?: Settings;
logo?: LogoSettings;
custom_hero?: CustomHeroConfig;
custom_header?: CustomNavigationItem[];
custom_footer?: CustomNavigationItem[];
}

The config is loaded with a 60-second cache:

export const getCachedConfig = unstable_cache(
async () => getConfig(),
['config'],
{ revalidate: 60 }
);

Environment Variables

VariableRequiredDescription
DATA_REPOSITORYYesGitHub URL for the content repository
GH_TOKENPrivate reposGitHub personal access token with repo read/write
GITHUB_BRANCHNoBranch to clone (defaults to main)
DISABLE_AUTO_SYNCNoSet to true to disable background sync
CRON_SECRETProductionSecret for the cron sync endpoint

Key Source Files

FilePurpose
lib/content.tsCore content parsing, fetching, caching, and similarity engine
lib/lib.tsContent path resolution, lazy initialization, filesystem utilities
lib/repository.tsGit clone, pull, push operations using isomorphic-git
lib/cache-config.tsCentralized cache tag and TTL definitions
lib/cache-invalidation.tsSafe cache invalidation with render-phase protection
scripts/clone.cjsInitial content clone during development setup
lib/services/sync-service.tsBackground sync manager
app/api/cron/sync/route.tsHTTP endpoint for triggering content sync