OptimoCMSDocs
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 → SettingsSecrets and variablesActions and add:

SecretValue
OPTIMOCMS_API_KEYYour live API key
OPTIMOCMS_SITE_IDYour 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.json

Example 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.ts

Next steps

On this page