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
- How It Works
- Quick Start
- API Contract
- Connecting to ScaleBlogger
- What the HTML Looks Like
- Complete Examples
- Handling the Featured Image
- CSS & Styling
- Testing Your Integration
- Schema Markup
- 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:
- Search for an existing category by name/slug
- Create the category if it doesn’t exist
- 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:
- Go to Website Settings → CMS Connection
- Choose "Custom / Other CMS" as the CMS type
- 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/postsPUT https://yourblog.com/api/posts/{id}
What the HTML Looks Like
The content field contains complete, ready-to-render HTML. It includes:
- A
<style>block at the top with all needed CSS (self-contained, no external stylesheets needed) - 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:
- Use it directly — Set it as the
<img src>or<meta property="og:image">on your blog page - Download and re-host — Fetch the image, store it in your own S3/CDN, and use your local copy (recommended for reliability)
- 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:
- Embedded in HTML — The
contentfield 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. - Separate
schema_markupfield — 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
contentHTML as-is (viadangerouslySetInnerHTML), the JSON-LD is already included. You only need the separateschema_markupfield 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:
- Accepts the POST and writes a markdown/MDX file to your content directory (or a headless CMS like Contentful/Strapi)
- Triggers a rebuild (e.g., call your Vercel/Netlify deploy hook)
- 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.