Multi-site automation
Create and manage dozens of sites programmatically with the OptimoCMS SDK. Ideal for franchises, agencies and white-label platforms.
Automate multi-site management
Do you have a franchise with 50 locations that each need their own website? Or do you manage dozens of client sites as an agency? The OptimoCMS SDK lets you automate the entire process.
In this tutorial you'll learn:
- Writing a script that creates multiple sites in a loop
- Respecting rate limits with built-in retry logic
- Error handling for robust batch operations
- Customising design tokens and content per location
Prerequisites
- Node.js 18+
npm install @optimocms/sdk- API key with
sites:writescope
Step 1 — Prepare location data
Create a JSON file with all locations:
[
{
"name": "Pizza Roma - Amsterdam",
"subdomain": "pizzaroma-amsterdam",
"address": "Keizersgracht 123, Amsterdam",
"phone": "+31 20 123 4567",
"color": "#D4380D"
},
{
"name": "Pizza Roma - Rotterdam",
"subdomain": "pizzaroma-rotterdam",
"address": "Coolsingel 45, Rotterdam",
"phone": "+31 10 987 6543",
"color": "#D4380D"
},
{
"name": "Pizza Roma - Utrecht",
"subdomain": "pizzaroma-utrecht",
"address": "Oudegracht 78, Utrecht",
"phone": "+31 30 456 7890",
"color": "#D4380D"
}
]Step 2 — Batch script with rate limit awareness
The SDK has built-in retry logic for 429 Too Many Requests. You can also add a delay between requests to stay well within the limit.
import { OptimoCMS, RateLimitError } from '@optimocms/sdk';
import { readFileSync } from 'node:fs';
interface Branch {
name: string;
subdomain: string;
address: string;
phone: string;
color: string;
}
const client = new OptimoCMS({
apiKey: process.env.OPTIMOCMS_API_KEY!,
maxRetries: 3,
});
const branches: Branch[] = JSON.parse(
readFileSync('./branches.json', 'utf-8')
);
async function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function createBranchSite(branch: Branch): Promise<string> {
const site = await client.sites.create({
name: branch.name,
subdomain: branch.subdomain,
language: 'nl',
});
const templates = await client.templates.list({ category: 'restaurant' });
await client.templates.install(site.id, {
templateId: templates.data[0].id,
designTokens: {
colorPrimary: branch.color,
fontHeading: 'Poppins',
fontBody: 'Inter',
radiusCard: '16px',
radiusButton: '8px',
},
});
await client.pages.create(site.id, {
title: 'Contact',
slug: 'contact',
status: 'published',
blocks: [
{
type: 'Hero',
props: { title: `Visit ${branch.name}`, subtitle: branch.address },
},
{
type: 'ContactInfo',
props: {
address: branch.address,
phone: branch.phone,
email: `info@${branch.subdomain}.nl`,
},
},
{
type: 'BookingWidget',
props: { serviceId: 'dinner' },
},
],
});
await client.sites.publish(site.id);
return site.id;
}
async function main() {
const results: Array<{ branch: string; siteId?: string; error?: string }> = [];
for (const branch of branches) {
try {
const siteId = await createBranchSite(branch);
results.push({ branch: branch.name, siteId });
console.log(`✓ ${branch.name} → ${branch.subdomain}.optimocms.com`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
results.push({ branch: branch.name, error: message });
console.error(`✗ ${branch.name}: ${message}`);
}
await sleep(500);
}
const success = results.filter(r => r.siteId).length;
const failed = results.filter(r => r.error).length;
console.log(`\nDone: ${success} succeeded, ${failed} failed out of ${branches.length} total`);
if (failed > 0) {
console.log('\nFailed locations:');
results.filter(r => r.error).forEach(r => {
console.log(` - ${r.branch}: ${r.error}`);
});
}
}
main().catch(console.error);Step 3 — Error handling and retry
The SDK handles rate limits automatically (exponential backoff, max 3 retries). For other errors you can add a retry wrapper:
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3,
delayMs = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof RateLimitError) {
const waitMs = error.retryAfter ? error.retryAfter * 1000 : delayMs * attempt;
console.log(`Rate limited, waiting ${waitMs}ms...`);
await sleep(waitMs);
continue;
}
if (attempt === maxAttempts) throw error;
console.log(`Attempt ${attempt} failed, retrying in ${delayMs * attempt}ms...`);
await sleep(delayMs * attempt);
}
}
throw new Error('Unreachable');
}
const siteId = await withRetry(() => createBranchSite(branch));Step 4 — Bulk update design tokens
Update existing sites in bulk with a new colour scheme:
async function updateAllBrandColors(newColor: string) {
for await (const site of client.sites.list()) {
if (!site.subdomain.startsWith('pizzaroma-')) continue;
await client.designTokens.update(site.id, {
colorPrimary: newColor,
});
console.log(`✓ ${site.name} updated`);
await sleep(200);
}
}
await updateAllBrandColors('#1D4ED8');Step 5 — MCP variant
With MCP you can achieve the same via natural language in Cursor or Claude:
Create 3 sites for the Pizza Roma franchise:
- Pizza Roma Amsterdam (subdomain: pizzaroma-amsterdam)
- Pizza Roma Rotterdam (subdomain: pizzaroma-rotterdam)
- Pizza Roma Utrecht (subdomain: pizzaroma-utrecht)
Use the restaurant template for each site with primary colour #D4380D and font Poppins.
Create a contact page with the address and phone number per location.
Publish all sites.Rate limits per tier
| Tier | Requests/min | Sites/hour |
|---|---|---|
| Free | 60 | 5 |
| Pro | 300 | 50 |
| Business | 1000 | 200 |
| Enterprise | Custom | Unlimited |
The SDK automatically respects the Retry-After header on a 429 response.
Tips for large batches
- Sequential, not parallel — Avoid race conditions by creating sites one at a time
- Checkpoint your progress — Save processed items to a file so you can resume after a crash
- Validate input — Check subdomains for validity (lowercase, no spaces, max 63 characters) before calling the API
- Dry-run mode — Add a
--dry-runflag that only logs what would happen
const DRY_RUN = process.argv.includes('--dry-run');
if (DRY_RUN) {
console.log(`[DRY RUN] Would create: ${branch.name} → ${branch.subdomain}`);
} else {
await createBranchSite(branch);
}Next steps
- CI/CD pipeline — Automate this script via GitHub Actions
- Webhook sync — Get notified when sites are published
- SDK Pagination — Efficiently iterate through large lists
Build a restaurant site
Create a complete restaurant website with menu, bookings, reviews and design tokens using the OptimoCMS SDK, MCP and REST API.
Webhook sync with CRM
Keep your CRM automatically synchronised with OptimoCMS via webhooks. Learn how to set up webhooks, process payloads and verify signatures.