Guides
CI/CD pipeline
Automate content updates and publishing with GitHub Actions and the OptimoCMS SDK. From staging to production in one push.
CI/CD pipeline with GitHub Actions
Automate your OptimoCMS workflow with GitHub Actions. Push content changes to a repository and let the pipeline automatically update and publish your site.
In this tutorial:
- Set up a GitHub Actions workflow
- Update content via the SDK in CI
- Staging (sandbox) → production flow
- Auto-publish on merge to
main
Prerequisites
- GitHub repository with your content/configuration
- OptimoCMS API key (stored as GitHub Secret)
- Optional: a sandbox site for staging
Step 1 — Configure GitHub Secrets
Go to your repository → Settings → Secrets and variables → Actions and add:
| Secret | Value |
|---|---|
OPTIMOCMS_API_KEY | Your live API key |
OPTIMOCMS_SITE_ID | Your production site ID |
OPTIMOCMS_SANDBOX_KEY | (optional) Sandbox API key for staging |
OPTIMOCMS_SANDBOX_SITE_ID | (optional) Sandbox site ID |
Step 2 — Content as code
Structure your content in the repository:
content/
├── pages/
│ ├── home.json
│ ├── about.json
│ └── menu.json
├── design-tokens.json
└── navigation.jsonExample content/pages/home.json:
{
"title": "Home",
"slug": "home",
"blocks": [
{
"type": "Hero",
"props": {
"title": "Welcome to our restaurant",
"subtitle": "The finest Italian cuisine since 1985",
"ctaText": "Reserve now",
"ctaLink": "/reservations"
}
},
{
"type": "FeaturedMenu",
"props": {
"title": "Chef's recommendation",
"items": ["bruschetta", "risotto", "tiramisu"]
}
}
]
}Step 3 — Deploy script
Create a scripts/deploy.ts that pushes content to OptimoCMS:
import { OptimoCMS } from '@optimocms/sdk';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const client = new OptimoCMS({
apiKey: process.env.OPTIMOCMS_API_KEY!,
});
const siteId = process.env.OPTIMOCMS_SITE_ID!;
interface PageContent {
title: string;
slug: string;
blocks: Array<{ type: string; props: Record<string, unknown> }>;
seo?: { title?: string; description?: string };
}
async function deployPages(): Promise<void> {
const pagesDir = join(process.cwd(), 'content', 'pages');
const files = readdirSync(pagesDir).filter(f => f.endsWith('.json'));
console.log(`Deploying ${files.length} pages...`);
for (const file of files) {
const content: PageContent = JSON.parse(
readFileSync(join(pagesDir, file), 'utf-8')
);
const existing = await client.pages.list(siteId, { status: 'published' });
const existingPage = existing.data.find(p => p.slug === content.slug);
if (existingPage) {
await client.pages.update(siteId, existingPage.id, {
title: content.title,
blocks: content.blocks,
seo: content.seo,
});
console.log(` ✓ Updated: ${content.title} (${content.slug})`);
} else {
await client.pages.create(siteId, {
title: content.title,
slug: content.slug,
status: 'published',
blocks: content.blocks,
seo: content.seo,
});
console.log(` ✓ Created: ${content.title} (${content.slug})`);
}
}
}
async function deployDesignTokens(): Promise<void> {
const tokensFile = join(process.cwd(), 'content', 'design-tokens.json');
try {
const tokens = JSON.parse(readFileSync(tokensFile, 'utf-8'));
await client.designTokens.update(siteId, tokens);
console.log(' ✓ Design tokens updated');
} catch {
console.log(' ⏭ No design-tokens.json found, skipped');
}
}
async function deployNavigation(): Promise<void> {
const navFile = join(process.cwd(), 'content', 'navigation.json');
try {
const nav = JSON.parse(readFileSync(navFile, 'utf-8'));
await client.navigation.update(siteId, nav);
console.log(' ✓ Navigation updated');
} catch {
console.log(' ⏭ No navigation.json found, skipped');
}
}
async function main(): Promise<void> {
console.log('🚀 OptimoCMS deploy started\n');
await deployPages();
await deployDesignTokens();
await deployNavigation();
const result = await client.sites.publish(siteId);
console.log(`\n✓ Site published (${result.status})`);
}
main().catch(error => {
console.error('Deploy failed:', error.message);
process.exit(1);
});Step 4 — GitHub Actions workflow
Create .github/workflows/deploy-content.yml:
name: Deploy Content to OptimoCMS
on:
push:
branches: [main]
paths:
- 'content/**'
workflow_dispatch:
jobs:
deploy-staging:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref != 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Deploy to Sandbox
env:
OPTIMOCMS_API_KEY: ${{ secrets.OPTIMOCMS_SANDBOX_KEY }}
OPTIMOCMS_SITE_ID: ${{ secrets.OPTIMOCMS_SANDBOX_SITE_ID }}
run: npx tsx scripts/deploy.ts
deploy-production:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Deploy to Production
env:
OPTIMOCMS_API_KEY: ${{ secrets.OPTIMOCMS_API_KEY }}
OPTIMOCMS_SITE_ID: ${{ secrets.OPTIMOCMS_SITE_ID }}
run: npx tsx scripts/deploy.ts
- name: Notify success
if: success()
run: echo "✓ Content deployed to production"
- name: Notify failure
if: failure()
run: echo "✗ Deploy failed — check logs"Step 5 — Staging → Production flow
Use a PR-based workflow with sandbox preview:
name: Preview on PR
on:
pull_request:
paths:
- 'content/**'
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Deploy to Sandbox
env:
OPTIMOCMS_API_KEY: ${{ secrets.OPTIMOCMS_SANDBOX_KEY }}
OPTIMOCMS_SITE_ID: ${{ secrets.OPTIMOCMS_SANDBOX_SITE_ID }}
run: npx tsx scripts/deploy.ts
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## 🔍 Content Preview\n\nChanges have been deployed to the sandbox:\n\n**Preview:** https://${process.env.SANDBOX_SUBDOMAIN}.sandbox.optimocms.com\n\nMerge this PR to deploy to production.`
});Workflow overview
Feature branch PR Main branch
│ │ │
│ push content/ │ │
├─────────────────►│ deploy to sandbox │
│ ├───────────────────► │
│ │ preview link in PR │
│ │ │
│ │ merge PR │
│ ├───────────────────────►│
│ │ │ deploy to production
│ │ ├───────────────────►Optional: Content validation
Add a validation step that checks whether JSON files are valid:
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const pagesDir = join(process.cwd(), 'content', 'pages');
const files = readdirSync(pagesDir).filter(f => f.endsWith('.json'));
let errors = 0;
for (const file of files) {
try {
const content = JSON.parse(readFileSync(join(pagesDir, file), 'utf-8'));
if (!content.title) throw new Error('Missing "title"');
if (!content.slug) throw new Error('Missing "slug"');
if (!Array.isArray(content.blocks)) throw new Error('"blocks" must be an array');
console.log(`✓ ${file}`);
} catch (err) {
console.error(`✗ ${file}: ${err instanceof Error ? err.message : err}`);
errors++;
}
}
if (errors > 0) {
console.error(`\n${errors} file(s) with errors`);
process.exit(1);
}
console.log(`\n✓ All ${files.length} files valid`);Add to your workflow before the deploy step:
- name: Validate content
run: npx tsx scripts/validate.tsNext steps
- Multi-site automation — The same pipeline for multiple sites
- Webhook sync — Get notified on publication
- SDK Error Handling — Robust error handling in CI