At Tenders SA, we recently undertook a migration that sounds minor on paper but had a massive impact on our developer experience and site performance: we moved our entire documentation system from local file reads to the Cloudflare Edge.
Here is why we did it, how we architected it using Cloudflare Workers and KV, and the code that makes it work.
The Problem: The "Monolithic" Content Trap
Like many Next.js projects, our documentation started as a folder of JSON files living in our repo (data/docs/*.json).
Our app would read these files at runtime (or build time) using standard file system calls:
// The old way (src/lib/docs/load-documentation.ts)
import fs from 'fs';
import path from 'path';
export function getDoc(slug) {
// ๐ซ This couples content to the build artifact!
const filePath = path.join(process.cwd(), 'data/docs', `${slug}.json`);
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
The issues with this approach:
- Rebuilds for Typos: Correcting a spelling mistake in the docs required a full CI/CD pipeline run and redeployment of the main application.
- Platform Limitations: As we looked to move certain services to edge runtimes (like Cloudflare Workers), we hit a wall. Edge workers generally cannot read from the local file system.
The Solution: Cloudflare KV + Workers
We decided to decouple the content from the application code.
- Storage: Cloudflare KV (Key-Value storage). It is eventually consistent, extremely fast for reads, and perfect for text data like documentation.
-
Delivery: A lightweight Cloudflare Worker acting as the
docs-api.
The Architecture
Instead of fs.readFile, our frontend now fetches data from an API.
-
Namespace:
TENDERS_DOCS -
Key Strategy:
-
doc:{slug}-> Stores the full content of a specific page. -
docs:tree-> Stores the navigation structure (sidebar categories, order, etc.).
-
Step 1: The Migration Script (Seeding KV)
We couldn't just drag-and-drop files. We needed a script to transform our local JSON files into a format Cloudflare KV accepts for bulk uploading.
We created scripts/generate-docs-kv-data.ts:
// scripts/generate-docs-kv-data.ts
import fs from 'fs';
import path from 'path';
const DOCS_DIR = path.join(__dirname, '../data/docs');
const OUTPUT_FILE = path.join(__dirname, 'docs_kv_bulk.json');
const generateKVData = () => {
const files = fs.readdirSync(DOCS_DIR);
const kvPairs = [];
const navigationTree = [];
files.forEach(file => {
if (!file.endsWith('.json')) return;
const content = fs.readFileSync(path.join(DOCS_DIR, file), 'utf8');
const docData = JSON.parse(content);
const slug = file.replace('.json', '');
// 1. Prepare the content key
kvPairs.push({
key: `doc:${slug}`,
value: JSON.stringify(docData) // Store as string
});
// 2. Build the navigation tree metadata (simplified)
navigationTree.push({
title: docData.title,
slug: slug,
category: docData.category
});
});
// 3. Add the tree key
kvPairs.push({
key: `docs:tree`,
value: JSON.stringify(navigationTree)
});
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(kvPairs, null, 2));
console.log(`โ
Generated ${kvPairs.length} KV pairs.`);
};
generateKVData();
Once generated, we upload the data via Wrangler:
npx wrangler kv:bulk put --namespace-id <YOUR_KV_ID> scripts/docs_kv_bulk.json
Step 2: The Worker Logic
The Worker is incredibly simple. It sits on the edge, intercepts the request, and queries KV.
// worker.ts
export interface Env {
TENDERS_DOCS: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// 1. Handle Navigation Request
if (url.pathname === '/docs/tree') {
const tree = await env.TENDERS_DOCS.get('docs:tree', 'json');
return new Response(JSON.stringify(tree), {
headers: { 'Content-Type': 'application/json' }
});
}
// 2. Handle Individual Pages
// Pattern: /docs/{slug}
const match = url.pathname.match(/^\/docs\/(.+)$/);
if (match) {
const slug = match[1];
// Fetch directly from KV
const doc = await env.TENDERS_DOCS.get(`doc:${slug}`, 'json');
if (!doc) {
return new Response('Doc not found', { status: 404 });
}
return new Response(JSON.stringify(doc), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600' // Cache heavily!
}
});
}
return new Response('Not found', { status: 404 });
},
};
Step 3: Decoupling the Frontend
Finally, we refactored our Next.js frontend components. Instead of importing a server-side file reader, we just fetch.
Before (Server Component):
const doc = await loadDocumentationData(slug); // โ Reads disk
After (Server Component):
const res = await fetch(`https://docs-api.tenders-sa.org/docs/${slug}`, {
next: { revalidate: 3600 }
});
const doc = await res.json(); // โ
Fetches from Edge
The Benefits
- Instant Content Updates: We can now update a specific KV key (via API or dashboard) and the documentation updates instantly without a redeploy.
- Performance: Cloudflare KV is distributed globally. Our documentation data is now physically closer to the user than our origin server ever was.
- Microservices Ready: Our documentation data is no longer trapped in the Next.js repo. Other services (like our chatbots or CLI tools) can hit the
docs-apito retrieve the same standardized content.
Have you moved static content to the Edge? Let me know in the comments!
Top comments (0)