Docs

Everything you need to add a BlogBuddy blog to your site, and everything the public API returns.

Getting started

Sign up, then follow the onboarding wizard: name your blog, pick a starting design, and you'll land on an API key and a ready-to-paste snippet. That key reads posts and templates from the public v1 endpoints below.

Create a free account to get your first key, or read on if you just want to see how the API works.

Authentication

Every request to the public API is authenticated with a bearer key, created from your dashboard's API Keys page. Every key belongs to one blog and reads only that blog; a revoked key stops working immediately. Only a blog's admin can create or revoke its keys.

Authorization: Bearer sk_live_your_api_key_here...

Keys are read-only, but anyone can read a key out of your page's source, and every request made with it counts against the blog admin's monthly allowance. Keep keys in server-side code where you can, and if one leaks, revoke it and create another.

React components

For React apps that want the platform-designed look without a script tag. Install the package:

npm install @blogbuddy/sdk

It ships separate entry points so a route that only needs two pure SEO functions does not pull React into its graph: @blogbuddy/sdk/server for the components, @blogbuddy/sdk/renderer for rendering the design in a browser-only app, @blogbuddy/sdk/hooks for fetching in the browser, @blogbuddy/sdk/feed for RSS and Atom, and @blogbuddy/sdk/seo plus @blogbuddy/sdk/json-ld for the SEO section below.

// lib/blogbuddy.ts - one config object, passed to everything
export const blog = {
  blog: 'your-blog-slug',
  apiKey: process.env.BLOGBUDDY_API_KEY!,
  siteUrl: 'https://acme.com',
  postUrlTemplate: '/blog/{slug}',
}

// app/blog/[slug]/page.tsx
import { BlogBuddyPost, blogBuddyMetadata, blogBuddyStaticParams } from '@blogbuddy/sdk/server'
import { blog } from '@/lib/blogbuddy'

export const generateMetadata = blogBuddyMetadata(blog)
export const generateStaticParams = blogBuddyStaticParams(blog)

export default async function Page({ params }) {
  return <BlogBuddyPost {...blog} slug={(await params).slug} />
}

Vite and Create React App

There are two pairs of components, and the dot is the whole difference.

  • Next.js — BlogBuddyPost and BlogBuddyList from @blogbuddy/sdk/server. No dot, no browser: these render on the server.
  • Vite, Create React App, any SPA — BlogBuddy.Post and BlogBuddy.List from @blogbuddy/sdk/renderer. The dot means the browser.

The server pair are React Server Components and a single-page build has no server to run them on. Nothing in the package imports from Next, so such a build resolves and bundles them and reports success — then throws at runtime with a message naming the dotted pair. It used to render a blank page instead.

import { BlogBuddy } from '@blogbuddy/sdk/renderer'

const KEY = import.meta.env.VITE_BLOGBUDDY_API_KEY

// Your list page
<BlogBuddy.List blog="your-blog-slug" apiKey={KEY} perPage={10}
                postUrlTemplate="/blog/{slug}" siteName="Acme" />

// One post
<BlogBuddy.Post blog="your-blog-slug" apiKey={KEY} slug={slug}
                postUrlTemplate="/blog/{slug}" siteName="Acme" />

That is the whole integration: it fetches the posts and the design, declares your custom fonts, applies your per-block and hover CSS, pages in place, and writes the <title>, description, canonical link, Open Graph tags and JSON-LD. React 19 hoists those into <head> even from a client render, so Google sees them — but they are written by JavaScript, which is why the preview-card row below is still a no.

Next.js vs. Vite/CRA, feature by feature

FeatureNext.js App RouterVite / Create React App
Your blog
The design from the Design tab
Yes
Yes
Custom fonts, hover styles, per-block CSS
Yes
Yes
All 13 constructor block types
Yes
Yes
Table of contents, related posts, carousel
Yes
Yes
Pagination and Load more
YesEach page gets a real URL, e.g. ?page=2
YesButtons that swap in place. No URL per page
What you write
YesBlogBuddyPost plus two route helpers
YesBlogBuddy.Post and BlogBuddy.List
Being found
The post's text in the HTML your host returns
Yes
NoAn empty shell, filled in later by JavaScript
Google indexes the post
YesStraight away
PartlyOnly once Google comes back to run the JavaScript
Slack, X, Facebook, LinkedIn and iMessage preview cards
Yes
NoNever. None of them run JavaScript
Per-post title and description
Yes
PartlySet in the browser. React 19 does this natively; React 18 and CRA need react-helmet-async
Canonical tag
Yes
PartlySame as above
Open Graph and Twitter tags
Yes
PartlyThey do reach the page, but no unfurler gets that far
JSON-LD: BlogPosting, Breadcrumb, Blog
YesEmitted automatically
PartlyClient-side, and it lands in the body rather than the head
sitemap.xml
YesOne line
YesPrebuild script, shown below
RSS and Atom feeds
YesOne line each
YesSame prebuild script
A share image generated per post
Yes
NoPoint og:image at the post cover image instead
A real HTML file per post (SSG / ISR)
Yes
No
Keys
API key stays off the visitor's machine
YesServer-side only
NoShips inside the bundle. Scope it read-only to this one blog

How to read this. Everything under Your blog is identical - the design renders the same either way. Every No and Partly below it comes from a single difference: whether HTML exists before the visitor’s browser starts working. The two rows you cannot buy back at any price are the preview card and the post text being in the response, because the programs that need those run no JavaScript at all. The rest are either fine as they stand (sitemap, feeds) or good enough for Google specifically.

SEO here is partial, not absent

Worth being precise about, because it decides whether this route works for your blog.

  • A sitemap and RSS/Atom feeds you can have in full. The @blogbuddy/sdk root entry is framework-free and runs in Node, so a prebuild script writes real static files into public/. Crawlers fetch those directly and execute nothing, which makes them worth exactly as much as the server-rendered ones.
  • Titles, descriptions and canonical tags mostly work. React 19 hoists <title>, <meta> and <link> into the document head from anywhere in the tree, client renders included, and Google runs JavaScript. On React 18 or Create React App, add react-helmet-async to do the same job.
  • Link previews do not work, and no library can fix it. Slack, X, Facebook, LinkedIn and iMessage execute no JavaScript. They read the HTML your host returned, which for a single-page app is the same empty shell on every route, so every shared link unfurls identically or not at all. This needs HTML that exists before the request finishes.

If link previews matter, prerender at build time - and note which kind, because it decides whether the design survives. A browser-snapshot prerenderer loads each route in a real browser and saves the settled DOM, which captures BlogBuddy.Post and its metadata. An SSR-style one renders in Node, where effects never run, so you get the empty shell. Otherwise use the server components above.

// scripts/generate-seo.mjs - run it before `vite build`
import { writeFileSync } from 'node:fs'
import { buildSitemapEntries, buildRssFeed } from '@blogbuddy/sdk'

const config = { siteUrl: 'https://acme.com', postUrlTemplate: '/blog/{slug}', siteName: 'Acme' }

const { data: posts } = await fetch(
  'https://blogbuddy.net/api/v1/posts?blog=your-blog-slug',
  { headers: { Authorization: `Bearer ${process.env.BLOGBUDDY_API_KEY}` } }
).then((r) => r.json())

writeFileSync('public/sitemap.xml',
  '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
  buildSitemapEntries(posts, config)
    .map((e) => `  <url><loc>${e.url}</loc><lastmod>${new Date(e.lastModified).toISOString()}</lastmod></url>`)
    .join('\n') + '\n</urlset>\n')

writeFileSync('public/feed.xml',
  buildRssFeed(posts, config, { feedUrl: 'https://acme.com/feed.xml' }).body)

Two keys, not one.

The script above runs on your machine or in CI, never in a browser, so its key can stay in an environment variable. The key you pass to BlogBuddy.Post ships inside your bundle - make it a separate key on the same blog, so if it gets lifted you can revoke it without breaking your build. Keys are read-only either way, so the worst a lifted one can do is read posts that are already public.

Data hook

For developers who want the raw post data and want to render entirely their own UI.

import { useBlogBuddy } from '@blogbuddy/sdk/hooks'

const { data: posts, loading, error } = useBlogBuddy({ blog: 'your-blog-slug', apiKey: key })

SEO setup

Your posts live on your domain, so the SEO value is yours - but that also means the canonical tags, link previews and sitemap have to be served from your site, not ours. BlogBuddy generates them; you serve them. Three steps.

1. Tell BlogBuddy where your posts live

In Blog settings → SEO, set your Site URL (e.g. https://acme.com) and your Post URL pattern (e.g. /blog/{slug}). Every absolute URL below is built from those two values, so they only need saying once.

The same panel holds the defaults every post inherits: Site name (the source shown on a shared link), a Default share image for posts with no cover of their own, a Default author, and a Language. They live in BlogBuddy rather than in your repo so that changing your site name is one edit and not two that drift apart. Paste a share image URL and its dimensions are measured for you - og:image:width and og:image:height are what let an unfurler lay the card out before the image arrives, and without them some platforms show no image at all.

Skip this and siteUrl comes back null. Nothing crashes, but your canonical tags and sitemap have no domain to point at, so keep a fallback in your own config (see the complete example below) rather than assuming it is set.

Per-post overrides

Each post has its own SEO panel in the editor, and everything in it is optional - a post that sets nothing inherits the blog defaults above.

  • Author - the byline. Free text, so a guest post can credit someone without a BlogBuddy login. Comes back as post.author and lands in JSON-LD.
  • Search title - what a result list shows when the headline is not the right thing there. The <h1> on the page never changes, and JSON-LD keeps the real title, since a headline contradicting the visible one is a structured-data violation.
  • Search description - used ahead of the excerpt that is otherwise derived from the opening of the post.
  • Hide from search engines - emits robots: noindex, follow and drops the post from buildSitemapEntries. The post stays public and linkable; a sitemap that lists a noindex URL is a contradiction Search Console reports as an error against the whole file.

2. Point one config object at it

Everything below takes the same object. The SEO fields in it are fallbacks - whatever you filled in above wins over them - so a blog whose settings are not filled in yet renders a slightly worse page rather than a broken one.

// lib/blogbuddy.ts
import type { BlogBuddyConfig } from '@blogbuddy/sdk/server'

export const blog: BlogBuddyConfig = {
  blog: 'your-blog-slug',
  apiKey: process.env.BLOGBUDDY_API_KEY!,
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
  postUrlTemplate: '/blog/{slug}',
  siteName: 'Acme',
  // Publishing shows up on the next refresh while developing; cached in production.
  revalidate: process.env.NODE_ENV === 'production' ? 300 : false,
}

3. The post page

This is the whole file. blogBuddyMetadata returns the canonical tag, the Open Graph and Twitter tags and the robots directives; blogBuddyStaticParams prerenders a real HTML file per post at build time and returns a 404 for an unknown slug; and BlogBuddyPost emits its own BlogPosting and BreadcrumbList structured data alongside the design. Do not also render ArticleJsonLd next to it - that emits the same block twice.

// app/blog/[slug]/page.tsx
import { BlogBuddyPost, blogBuddyMetadata, blogBuddyStaticParams } from '@blogbuddy/sdk/server'
import { blog } from '@/lib/blogbuddy'

export const generateMetadata = blogBuddyMetadata(blog)
export const generateStaticParams = blogBuddyStaticParams(blog)

export default async function Page({ params }) {
  const { slug } = await params
  return <BlogBuddyPost {...blog} slug={slug} breadcrumbHome="Acme" />
}

The page is still yours - wrap the component in your own layout, header and back link. Pass jsonLd={false} if you would rather emit your own structured data.

One thing that lives outside this file: set metadataBase in your root layout's metadata export. Next needs it to resolve Open Graph image URLs to absolute ones, and warns at build time without it.

4. The index page

blogBuddyMetadata works out which kind of page it is from whether the route has a slug param, so it is the same helper here. Page 2 canonicalises to itself rather than to page one - without that, every page past the first is declared a duplicate and the posts on them lose their only discovery path.

// app/blog/page.tsx
import { BlogBuddyList, blogBuddyMetadata } from '@blogbuddy/sdk/server'
import { blog } from '@/lib/blogbuddy'

export const generateMetadata = blogBuddyMetadata(blog, { title: 'Blog' })

export default async function Page({ searchParams }) {
  const { page } = await searchParams
  return <BlogBuddyList {...blog} perPage={10} page={Number(page) || 1} title="Blog" />
}

The page controls come from the Pagination block on your list design, styled exactly as you left it in the Design tab. The server list renders them as real links rather than buttons, and marks the current page with aria-current instead of disabled. Links default to ?page=N on the current path; pass pageHref="/blog/page/{page}" if you route pages as path segments. If the block is set to “Load more”, page 2 serves posts 1–20 rather than 11–20, so following the link really does add to what is on screen.

5. Sitemap, feeds and robots

A sitemap may only list URLs on the host that serves it, which is why these files live in your app rather than ours. One line each.

// app/sitemap.ts
import { blogBuddySitemap } from '@blogbuddy/sdk/server'
import { blog } from '@/lib/blogbuddy'

export default blogBuddySitemap(blog, {
  extra: [{ url: 'https://acme.com', lastModified: new Date().toISOString(), changeFrequency: 'daily', priority: 1 }],
})

// app/blog/feed.xml/route.ts
export const GET = blogBuddyFeed(blog)

// app/blog/atom.xml/route.ts
export const GET = blogBuddyFeed(blog, { format: 'atom' })

// app/robots.ts
export default function robots() {
  return { rules: { userAgent: '*', allow: '/' }, sitemap: 'https://acme.com/sitemap.xml' }
}

A feed has to name its own address, and the default assumes the paths above. Mounting one elsewhere takes a path, resolved against your site URL so the origin is never written twice - blogBuddyFeed(blog, { feedUrl: '/feed.xml' }). And if your index does not sit at the parent path of your posts - a list at / with posts at /posts/{slug} - set pageUrlTemplate on your config, since that is what the index's own canonical tag is built from.

Posts you marked Hide from search engines are dropped from the sitemap automatically: a sitemap listing a noindex URL is a contradiction Search Console reports as an error against the whole file. Advertise the feeds from your index with feedLinkTags, and pass fullContent: true to include each post's HTML - off by default, since a full-text feed is a real trade-off and multiplies the document size.

If you need more control

Every helper above is a thin wrapper over functions you can call yourself, for the cases they do not cover: getPost, getPosts and seoConfig from /server; buildPostMetadata, buildBlogMetadata, buildSitemapEntries, buildBreadcrumbJsonLd and buildBlogJsonLd from /seo; and buildRssFeed / buildAtomFeed from /feed.

Two worth knowing about by name. Set postImageUrlTemplate on your config to a route that generates a card per post - without it, every post with no cover shares one site-wide picture, so the preview says what the site is rather than what the post is. And set pageUrlTemplate if your index pages are path segments rather than a query string, so their canonical tags point at the right URLs.

A few more worth having

  • Paginated indexes - set pageUrlTemplate and pass page to buildBlogMetadata. Without it every page of your index canonicalises to page one, which declares pages 2+ duplicates and removes the only discovery path to the posts on them.
  • Breadcrumbs - buildBreadcrumbJsonLd(post, config) is what puts "yoursite.com › Blog › Post title" under a search result instead of a raw URL.
  • Index structured data - buildBlogJsonLd(posts, config) describes the list page as a blog carrying those posts, rather than an unexplained set of links.
  • Per-post share images - set postImageUrlTemplate to a route on your site that generates a card. Without it every post with no cover image shares one site-wide picture, so the preview says what the site is rather than what the post is.

Checking it worked

  • curl a post URL and confirm the article text is in the response body. If it isn't, the page is rendering client-side and no amount of metadata will help.
  • Paste a post URL into Slack. A title, description and image mean your Open Graph tags are right.
  • Run the URL through Google's Rich Results Test to validate the JSON-LD.
  • Submit your sitemap in Google Search Console, then check that the post count matches.

REST API reference

All endpoints are versioned under /api/v1 and require the Authorization header above.

GET /api/v1/posts?blog=slug

Every published (or now-due scheduled) post for a blog, most recent first. Each post includes an excerpt and pre-rendered contentHtml. Add page and per_page to paginate (omit both to get every post in one response) - the response's meta then includes total and totalPages. BlogBuddyList does this automatically once a Pagination block is added to the list page in the Design tab. Add fields=sitemap to get slugs and dates only, with no post bodies - what a sitemap route needs, and a fraction of the payload.

GET /api/v1/posts/[slug]?blog=slug

A single post, plus view_count, headings (for a table of contents), and relatedPosts (up to 3 others from the same blog, or the same collection - see the Related posts block's settings in the Design tab). Every post from these endpoints also carries its SEO fields: author, seoTitle, seoDescription and seoNoindex. Reading this endpoint does not count a view - it is cached at the edge, and a cached response never reaches us. Views are recorded by the separate beacon below, which the SDK calls for you.

POST /api/v1/posts/[slug]/view?blog=slug

Records one read of a post. Both SDK paths call this for you after the post renders, so you only need it if you fetch the API yourself. It takes no body, answers 202, and is never cached. The same reader reloading inside a short window is counted once, and requests that do not look like a browser are not counted at all. Pass countViews= to BlogBuddyPost, or to useBlogBuddyPost, to render a post without counting it.

GET /api/v1/blog?blog=slug

The blog's own configuration: its name and slug, plus every SEO default set in Blog settings → SEO - siteUrl, postUrlTemplate, siteName, defaultImage (with defaultImageWidth/defaultImageHeight), authorName and locale. The field names match SeoSiteConfig exactly, so the response spreads straight into one.

GET /api/v1/templates?blog=slug

The blog's active list/post page designs (or the built-in default if nothing's been published yet), plus any custom fonts to load.

Constructor block types

Every list/post page is built from these blocks in the visual constructor:

  • Container, Heading, Text, Image, Button, Spacer, Divider, basic layout and content primitives.
  • Post body, renders the full rich content of the current post.
  • Post list (loop), only in a list-page design, repeats a card template once per post.
  • Table of contents, an anchor-link outline of the current post's own headings.
  • Related posts, up to 3 other posts from the same blog or collection.
  • Photo carousel, a sideways strip of the extra photos attached to the post, beside its cover.
  • Pagination, only in a list-page design, as numbered pages or a “Load more” button. Rendered as real links on the server, so every page has a crawlable URL.