Custom CMS Integration Guide

Step-by-step guide for developers who have their own blog (Next.js, Astro, Express, etc.) and want ScaleBlogger to publish directly to it via a simple REST API.

Custom CMS Integration Guide

For developers who have their own blog (Next.js, Vite, Astro, Remix, etc.) and want ScaleBlogger to publish directly to it.

WordPress and Ghost have built-in integrations. For everything else — any custom blog, headless CMS, or static-site framework — you implement a small REST API that ScaleBlogger calls when it's time to publish.


Table of Contents

  1. How It Works
  2. Quick Start
  3. API Contract
  4. Connecting to ScaleBlogger
  5. What the HTML Looks Like
  6. Complete Examples
  7. Handling the Featured Image
  8. CSS & Styling
  9. Testing Your Integration
  10. Schema Markup
  11. FAQ

How It Works

ScaleBlogger                          Your Blog
    │                                     │
    │  1. GET /posts?slug=my-article      │   (optional slug check)
    │ ──────────────────────────────────►  │
    │  ◄──── [] or [existing post]  ───── │
    │                                     │
    │  2. POST /posts                     │   (create new article)
    │     { title, content, slug, ... }   │
    │ ──────────────────────────────────►  │
    │  ◄──── { id, url }  ────────────── │
    │                                     │
    │  3. PUT /posts/:id                  │   (update existing, if refreshed)
    │     { content, featured_image_url } │
    │ ──────────────────────────────────►  │
    │  ◄──── { id, url }  ────────────── │
    │                                     │
    │  4. GET /categories?search=...      │   (optional, if categories_endpoint set)
    │ ──────────────────────────────────►  │
    │  ◄──── [{ id, name, slug }]  ───── │
    │                                     │
    │  5. POST /categories                │   (create missing category)
    │     { name, slug }                  │
    │ ──────────────────────────────────►  │
    │  ◄──── { id, name, slug }  ──────  │

That's it. ScaleBlogger calls your API. You just need to expose these endpoints.


Quick Start

Minimum viable integration — you only need one endpoint to start publishing:

POST /posts

That accepts a JSON body and returns { id, url }. ScaleBlogger will call this with fully formatted HTML content, a title, a slug, and metadata. You save it to your database and return the live URL.

The slug-check (GET /posts?slug=...) and update (PUT /posts/:id) endpoints are optional but recommended.


API Contract

Authentication

Every request from ScaleBlogger includes a Bearer token in the Authorization header:

Authorization: Bearer <your-api-key>
Content-Type: application/json

You generate this API key on your side and enter it in ScaleBlogger when setting up the CMS connection. Validate it on every request.


1. Check for Duplicate Slugs (Optional)

ScaleBlogger calls this before creating a post to avoid slug collisions.

GET {your_api_url}/posts?slug={slug}
Authorization: Bearer {api_key}

Expected Response

If no post with that slug exists:

[]

Return an empty array (HTTP 200).

If a post with that slug already exists:

[{ "id": "existing-post-id", "slug": "the-slug" }]

Return an array with at least one element. ScaleBlogger will then append a suffix to avoid the collision (e.g., the-slug-a1b2c3d4).

If this endpoint doesn't exist (404): ScaleBlogger will skip the check and proceed with publishing. No harm done.


2. Create a Post

This is the main endpoint. ScaleBlogger sends a fully formatted article here.

POST {your_api_url}/posts
Authorization: Bearer {api_key}
Content-Type: application/json

Request Body

{
  "title": "How to Optimize Your Next.js Blog for SEO",
  "content": "<style>...</style>\n\n<h2 class=\"heading-2\">Introduction</h2>\n<p class=\"paragraph\">...</p>",
  "slug": "optimize-nextjs-blog-seo",
  "status": "published",
  "excerpt": "Learn the essential SEO techniques for Next.js blogs...",
  "meta_description": "Learn the essential SEO techniques for Next.js blogs...",
  "featured_image_url": "https://images.unsplash.com/photo-...",
  "seo_title": "How to Optimize Your Next.js Blog for SEO | YourBrand",
  "og_title": "How to Optimize Your Next.js Blog for SEO | YourBrand",
  "og_image": "https://images.unsplash.com/photo-...",
  "canonical_url": null,
  "schema_markup": {
    "@context": "https://schema.org",
    "@graph": [
      {
        "@type": "Article",
        "headline": "How to Optimize Your Next.js Blog for SEO",
        "description": "Learn the essential SEO techniques..."
      },
      {
        "@type": "FAQPage",
        "mainEntity": [
          {
            "@type": "Question",
            "name": "What is the best way to optimize Next.js for SEO?",
            "acceptedAnswer": {
              "@type": "Answer",
              "text": "Use server-side rendering, add proper meta tags..."
            }
          }
        ]
      }
    ]
  },
  "tags": ["nextjs", "seo", "web-development"],
  "categories": ["Web Development"],
  "focus_keywords": ["nextjs seo", "blog optimization"]
}
Field Type Description
title string Article title (plain text)
content string Complete HTML — ready to render, includes <style> block, all article HTML, and embedded <script type="application/ld+json"> schema markup
slug string URL-safe slug (lowercase, hyphenated, stop words removed, max ~60 chars)
status string Always "published"
excerpt string Short description / meta description
meta_description string SEO meta description (same as excerpt)
featured_image_url string | null URL of the hero/featured image (hosted externally)
seo_title string | null Optimized title for search engines (may differ from title)
og_title string | null Open Graph title for social sharing
og_image string | null Open Graph image URL for social sharing
canonical_url string | null Canonical URL (if different from the published URL)
schema_markup object | null JSON-LD structured data — Article, FAQPage, HowTo, and other schema.org types. See Schema Markup below.
tags string[] Tags/keywords for the article
categories string[] or Array<{id, name}> Category names as plain strings by default. If a categories endpoint is configured, resolved objects with id and name are sent instead.
focus_keywords string[] Primary SEO keywords the article targets

Expected Response

Return HTTP 200 or 201 with JSON:

{
  "id": "your-internal-post-id",
  "url": "https://yourblog.com/blog/optimize-nextjs-blog-seo"
}
Field Type Required Description
id string | number Yes Your internal post identifier (used for future updates)
url or link string Yes The live public URL where the article is accessible

ScaleBlogger records both id and url — the id is used if the article is later refreshed/updated, and the url is displayed to the user and used for indexing.

Error Response

Return any 4xx or 5xx status with a body. ScaleBlogger captures the first 200 characters of the response body for error reporting.


3. Update a Post

Called when an existing article is refreshed or content-synced.

PUT {your_api_url}/posts/{id}
Authorization: Bearer {api_key}
Content-Type: application/json

Where {id} is the id you returned from the original POST /posts response.

Request Body

{
  "content": "<style>...</style>\n\n<h2>Updated Introduction</h2>\n<p>...</p>",
  "featured_image_url": "https://images.unsplash.com/photo-..."
}
Field Type Description
content string Updated complete HTML
featured_image_url string | null Updated featured image URL

Note: On update, content, featured_image_url, and all SEO fields (seo_title, og_title, og_image, schema_markup, tags, focus_keywords) are sent. Title and slug are not changed.

Expected Response

{
  "id": "your-internal-post-id",
  "url": "https://yourblog.com/blog/optimize-nextjs-blog-seo"
}

Same format as the create response.


4. Category Resolution (Optional)

By default, ScaleBlogger sends category names as plain strings in the categories field (e.g., ["Web Development"]). Your API can use these strings however it likes.

If you want ScaleBlogger to automatically create missing categories and resolve existing ones by ID (similar to how WordPress works), you can configure a categories endpoint. When configured, ScaleBlogger will:

  1. Search for an existing category by name/slug
  2. Create the category if it doesn’t exist
  3. Send the resolved { id, name } object instead of a plain string

Categories Endpoint Contract

Your categories endpoint must support two operations:

Search for a category:

GET {categories_endpoint}?search={name}&per_page=100
Authorization: Bearer {api_key}

Return an array of matching categories:

[
  { "id": 5, "name": "Web Development", "slug": "web-development" },
  { "id": 12, "name": "Web Design", "slug": "web-design" }
]

ScaleBlogger matches on slug or name (case-insensitive, with underscore/hyphen normalization).

Create a category:

POST {categories_endpoint}
Authorization: Bearer {api_key}
Content-Type: application/json
{
  "name": "Web Development",
  "slug": "web-development"
}

Return the created category:

{ "id": 5, "name": "Web Development", "slug": "web-development" }

How It Changes the Payload

Configuration categories field in payload
No categories_endpoint (default) ["Web Development"] (plain strings)
With categories_endpoint [{ "id": 5, "name": "Web Development" }] (resolved objects)
Resolution fails (endpoint down, etc.) ["Web Development"] (falls back to plain strings)

Note: Category resolution is applied on both initial publish (POST /posts) and content updates (PUT /posts/:id).


Connecting to ScaleBlogger

Once your API is running, connect it in ScaleBlogger:

  1. Go to Website Settings → CMS Connection
  2. Choose "Custom / Other CMS" as the CMS type
  3. Enter your details:
Setting Value Example
API URL Your API base URL (no trailing slash) https://yourblog.com/api
API Key The Bearer token your API expects sk_live_abc123...
Categories Endpoint (optional) Full URL to your categories API. Enables category create-or-resolve. https://yourblog.com/api/categories

ScaleBlogger will then call:

  • GET https://yourblog.com/api/posts?slug=...
  • POST https://yourblog.com/api/posts
  • PUT https://yourblog.com/api/posts/{id}

What the HTML Looks Like

The content field contains complete, ready-to-render HTML. It includes:

  1. A <style> block at the top with all needed CSS (self-contained, no external stylesheets needed)
  2. Semantic HTML with CSS classes from the "generic" profile

Example HTML Content

<style>
  .heading-1 { font-size: 2.5rem; font-weight: 700; margin: 0 0 1rem 0; }
  .heading-2 { font-size: 2rem; font-weight: 600; margin: 0 0 0.75rem 0; }
  .heading-3 { font-size: 1.5rem; font-weight: 600; margin: 0 0 0.5rem 0; }
  .paragraph { margin: 0 0 1rem 0; line-height: 1.6; }
  .blockquote { border-left: 4px solid #e2e8f0; padding-left: 1rem; margin: 1.5rem 0; font-style: italic; }
  .callout { padding: 1rem; margin: 1rem 0; border-radius: 6px; border-left: 4px solid; }
  .callout-info { background-color: #f0f9ff; border-color: #0ea5e9; color: #0369a1; }
  .callout-warning { background-color: #fefce8; border-color: #eab308; color: #a16207; }
  .content-list { margin: 0 0 1rem 0; padding-left: 1.5rem; }
  .content-table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
  .content-table th, .content-table td { border: 1px solid #e2e8f0; padding: 12px 16px; }
  /* ... more styles ... */
</style>

<h2 class="heading-2" id="why-seo-matters">Why SEO Matters for Next.js Blogs</h2>

<p class="paragraph">Search engine optimization is critical for any blog...</p>

<div class="callout callout-info">
  <p>Next.js provides built-in support for meta tags via the Metadata API.</p>
</div>

<h3 class="heading-3">Key Ranking Factors</h3>

<ul class="content-list">
  <li>Page load speed</li>
  <li>Mobile responsiveness</li>
  <li>Content quality and depth</li>
</ul>

<table class="content-table">
  <thead><tr><th>Factor</th><th>Impact</th></tr></thead>
  <tbody>
    <tr><td>Core Web Vitals</td><td>High</td></tr>
    <tr><td>Content Freshness</td><td>Medium</td></tr>
  </tbody>
</table>

<blockquote class="blockquote">
  <p>The best SEO strategy is to create content that genuinely helps your readers.</p>
  <cite class="quote-citation">— Google Search Central</cite>
</blockquote>

<div class="sb-video" data-video-id="dQw4w9WgXcQ" data-platform="youtube" style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden">
  <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" style="position:absolute;top:0;left:0;width:100%;height:100%" frameborder="0" allowfullscreen></iframe>
</div>

<div class="sources-footer">
  <h3 class="sources-heading">Sources</h3>
  <ol class="sources-list">
    <li class="source-item"><a href="https://..." target="_blank" rel="noopener noreferrer">Source Title</a></li>
  </ol>
</div>

CSS Classes Used

Class Element
heading-1 through heading-6 <h1> to <h6>
paragraph <p>
content-list <ul> / <ol>
blockquote <blockquote>
quote-citation <cite>
callout, callout-info, callout-warning, callout-error <div> info/warning/error boxes
content-figure <figure>
content-table <table>
code-block <pre>
sb-video Video embed wrapper
sb-toc Table of contents <nav>
sources-footer, sources-list, source-item Sources section

Complete Examples

Next.js API Route

Create app/api/posts/route.ts (App Router) or pages/api/posts.ts (Pages Router):

App Router (app/api/posts/route.ts)

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; // your database client

const API_KEY = process.env.SCALEBLOGGER_API_KEY!;

function authenticate(request: NextRequest): boolean {
  const auth = request.headers.get("authorization");
  return auth === `Bearer ${API_KEY}`;
}

// GET /api/posts?slug=... — Slug collision check
export async function GET(request: NextRequest) {
  if (!authenticate(request)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const slug = request.nextUrl.searchParams.get("slug");
  if (!slug) {
    return NextResponse.json([]);
  }

  const existing = await db.post.findMany({
    where: { slug },
    select: { id: true, slug: true },
  });

  return NextResponse.json(existing);
}

// POST /api/posts — Create a new article
export async function POST(request: NextRequest) {
  if (!authenticate(request)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const { title, content, slug, excerpt, meta_description, featured_image_url, schema_markup, seo_title, tags, focus_keywords } = body;

  const post = await db.post.create({
    data: {
      title,
      content,       // Store the HTML — render it on your blog pages
      slug,
      excerpt,
      metaDescription: meta_description,
      featuredImage: featured_image_url,
      publishedAt: new Date(),
    },
  });

  return NextResponse.json({
    id: post.id,
    url: `https://yourblog.com/blog/${post.slug}`,
  }, { status: 201 });
}

Update endpoint (app/api/posts/[id]/route.ts)

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

const API_KEY = process.env.SCALEBLOGGER_API_KEY!;

// PUT /api/posts/:id — Update an existing article
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const auth = request.headers.get("authorization");
  if (auth !== `Bearer ${API_KEY}`) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();
  const { content, featured_image_url } = body;

  const post = await db.post.update({
    where: { id: params.id },
    data: {
      content,
      featuredImage: featured_image_url,
      updatedAt: new Date(),
    },
  });

  return NextResponse.json({
    id: post.id,
    url: `https://yourblog.com/blog/${post.slug}`,
  });
}

Express / Node.js

const express = require("express");
const app = express();

app.use(express.json({ limit: "5mb" })); // articles can be large

const API_KEY = process.env.SCALEBLOGGER_API_KEY;

function auth(req, res, next) {
  if (req.headers.authorization !== `Bearer ${API_KEY}`) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  next();
}

// Slug check
app.get("/posts", auth, async (req, res) => {
  const { slug } = req.query;
  if (!slug) return res.json([]);

  const existing = await Post.find({ slug });
  res.json(existing.map((p) => ({ id: p._id, slug: p.slug })));
});

// Create post
app.post("/posts", auth, async (req, res) => {
  const { title, content, slug, excerpt, meta_description, featured_image_url, schema_markup, seo_title, tags, focus_keywords } = req.body;

  const post = await Post.create({
    title,
    content,
    slug,
    excerpt,
    metaDescription: meta_description,
    featuredImage: featured_image_url,
    publishedAt: new Date(),
  });

  res.status(201).json({
    id: post._id,
    url: `https://yourblog.com/blog/${post.slug}`,
  });
});

// Update post
app.put("/posts/:id", auth, async (req, res) => {
  const { content, featured_image_url } = req.body;

  const post = await Post.findByIdAndUpdate(
    req.params.id,
    { content, featuredImage: featured_image_url, updatedAt: new Date() },
    { new: true }
  );

  res.json({
    id: post._id,
    url: `https://yourblog.com/blog/${post.slug}`,
  });
});

app.listen(3000);

Handling the Featured Image

The featured_image_url is a URL to an externally hosted image (typically from Unsplash, a stock provider, or an AI image generator). Your options:

  1. Use it directly — Set it as the <img src> or <meta property="og:image"> on your blog page
  2. Download and re-host — Fetch the image, store it in your own S3/CDN, and use your local copy (recommended for reliability)
  3. Ignore it — If you handle featured images separately, just discard this field

The image is not included in the content HTML — it's provided separately so you can position it however your blog template requires (hero banner, sidebar thumbnail, social card, etc.).


CSS & Styling

Option A: Use the Included Styles (Easiest)

The content HTML comes with a <style> block. If you render the HTML as-is (e.g., via dangerouslySetInnerHTML in React), the styles will just work.

// In your blog post page
<article dangerouslySetInnerHTML={{ __html: post.content }} />

Option B: Strip Styles and Use Your Own

If you want the content to inherit your blog's existing styles, strip the <style> block:

function stripStyles(html: string): string {
  return html.replace(/<style>[\s\S]*?<\/style>/gi, "").trim();
}

The HTML uses semantic elements (<h2>, <p>, <ul>, <table>, <blockquote>, etc.) that your existing CSS likely already styles. The CSS classes (like heading-2, paragraph) are optional — the content works fine without them.

Option C: Override Specific Styles

Add CSS in your own stylesheet to override the included styles:

/* Override callout styling to match your design system */
.callout { border-radius: 12px; font-family: inherit; }
.callout-info { background-color: var(--info-bg); border-color: var(--info-border); }

/* Override table styling */
.content-table { border: none; border-radius: 8px; overflow: hidden; }

Testing Your Integration

1. Test with cURL

# Test the create endpoint
curl -X POST https://yourblog.com/api/posts \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Test Article from ScaleBlogger",
    "content": "<h2 class=\"heading-2\">Hello World</h2><p class=\"paragraph\">This is a test article.</p>",
    "slug": "test-article-scaleblogger",
    "status": "published",
    "excerpt": "A test article",
    "meta_description": "A test article",
    "featured_image_url": null
  }'

# Expected: {"id": "...", "url": "https://yourblog.com/blog/test-article-scaleblogger"}
# Test the slug check
curl "https://yourblog.com/api/posts?slug=test-article-scaleblogger" \
  -H "Authorization: Bearer your-api-key"

# Expected: [{"id": "...", "slug": "test-article-scaleblogger"}]
# Test the update endpoint
curl -X PUT https://yourblog.com/api/posts/YOUR_POST_ID \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "<h2 class=\"heading-2\">Updated Content</h2><p class=\"paragraph\">This article has been refreshed.</p>",
    "featured_image_url": null
  }'

# Expected: {"id": "...", "url": "https://yourblog.com/blog/test-article-scaleblogger"}

2. Test from ScaleBlogger

After setting up the connection, publish a draft article. Check:

  • Article appears on your blog at the expected URL
  • Formatting looks correct (headings, lists, tables, callouts, etc.)
  • Featured image (if provided) is displayed
  • The published URL shown in ScaleBlogger matches your actual URL

Schema Markup

ScaleBlogger automatically generates JSON-LD structured data for every article. This improves search visibility by giving search engines and AI assistants machine-readable article metadata.

How Schema Markup is Delivered

Schema markup reaches your site in two ways:

  1. Embedded in HTML — The content field includes <script type="application/ld+json"> tags at the end of the HTML. If you render content as-is, schema markup is already on the page.
  2. Separate schema_markup field — The full structured data object is also sent as a standalone JSON field, so you can place it in your page <head> or process it however you prefer.

Schema Types Included

Depending on article content, schema_markup may contain:

Schema Type When Included
Article Always — headline, description, author, datePublished
FAQPage When the article has a People Also Ask (PAA) section
HowTo When the article contains step-by-step instructions
Table When the article includes data tables
Organization Based on the website's brand information
BreadcrumbList For site navigation context

Using schema_markup in Your API Handler

// In your POST /api/posts handler:
const { title, content, slug, schema_markup, ...rest } = req.body;

// Option A: content HTML already has JSON-LD embedded — just render it
await db.post.create({ data: { title, content, slug } });

// Option B: Store schema_markup separately for your page <head>
if (schema_markup) {
  await db.post.create({
    data: {
      title, content, slug,
      schemaMarkup: JSON.stringify(schema_markup),
    },
  });
}

// Then in your blog page template:
// <head>
//   <script type="application/ld+json">{post.schemaMarkup}</script>
// </head>

Tip: If you render content HTML as-is (via dangerouslySetInnerHTML), the JSON-LD is already included. You only need the separate schema_markup field if you want to place it in <head> or have your own structured data pipeline.


FAQ

What is schema_markup and do I need to use it?

schema_markup is JSON-LD structured data that helps search engines understand your article. It's optional to handle — the content HTML already includes JSON-LD as a <script> tag. If you render content as-is, schema markup is already on the page. The separate field is provided in case you want to place it in <head> or integrate with your own SEO pipeline.

What if my API uses different field names?

ScaleBlogger sends the exact fields documented above (title, content, slug, status, excerpt, meta_description, featured_image_url, plus SEO fields: seo_title, og_title, og_image, canonical_url, schema_markup, tags, categories, focus_keywords). Map them to your schema in your API handler — there's no configuration for custom field mapping.

What if I don't have a slug check endpoint?

That's fine. ScaleBlogger gracefully handles a 404 on the slug check and proceeds to publish. The slug check only prevents duplicate slugs — if you handle uniqueness in your database (e.g., a unique constraint), you may not need it.

Can I use a different URL path instead of /posts?

No — ScaleBlogger appends /posts to whatever API URL you configure. If your API URL is https://yourblog.com/api/v1, ScaleBlogger will call https://yourblog.com/api/v1/posts.

What if my blog is a static site (Astro, Hugo, Gatsby)?

You'll need a small API server that:

  1. Accepts the POST and writes a markdown/MDX file to your content directory (or a headless CMS like Contentful/Strapi)
  2. Triggers a rebuild (e.g., call your Vercel/Netlify deploy hook)
  3. Returns the expected { id, url } response

The rebuild can be async — return the expected URL immediately and trigger the build in the background.

How big can the content be?

Article HTML can be 20-100KB+ depending on article length and whether styles are included. Make sure your API accepts large JSON bodies (set body-parser limit to at least 5MB).

Does ScaleBlogger retry on failure?

For the generic CMS publisher, ScaleBlogger does not automatically retry on failure. If your API returns a non-2xx status, the publish fails and the error is shown to the user. They can retry manually. Make sure your API is reliable.

What content types are in the HTML?

The HTML may contain: headings (h1-h6), paragraphs, ordered/unordered lists, tables, blockquotes, callout boxes (info/warning/error), code blocks, images, video embeds (YouTube iframes), downloadable template links, and a sources/references footer. All are standard HTML elements with CSS classes.

How do I enable automatic category creation for my custom API?

Add a categories_endpoint to your CMS connection configuration in ScaleBlogger (under Website Settings → CMS Connection). Point it to a REST endpoint on your blog that supports GET ?search=... (to list/search categories) and POST (to create new ones). Once configured, ScaleBlogger will automatically search for existing categories and create any that are missing — just like WordPress. See Category Resolution for full details.

Can I request raw markdown instead of HTML?

Not currently. ScaleBlogger always sends formatted HTML. If you need markdown, you could convert it on your side using a library like turndown.