@floggy/cms
v0.5.0
Published
Floggy CMS SDK - the single dependency to build an SEO-complete blog on Floggy's headless content API
Downloads
1,590
Maintainers
Readme
@floggy/cms
The single dependency you install to build a blog on Floggy.
Floggy renders your content server-side (html, markdown, SEO metadata, JSON-LD, feeds) and embeds the finished output in its API responses. This SDK is a thin, fully typed client over that API, for reading and for writing. Zero runtime dependencies - it uses the platform fetch.
- Read a published blog with no key: posts, tags, SEO metadata, JSON-LD, sitemap, RSS, robots.
- Write with an
flg_key: create posts from Markdown, update, publish, schedule, unpublish, delete, bulk. - Preview an unpublished post by slug with
posts.draft, the primitive behind a/drafts/[slug]route. - Convert Markdown to Tiptap and back with
markdownToTiptap/tiptapToMarkdown, no dependencies. - Store structured content in Agent Collections: schemas, versions, labels, A/B variants.
Install
bun add @floggy/cms
# or
npm install @floggy/cmsQuickstart
A working blog index plus a post page, with SEO, in about 15 lines.
import { createClient } from "@floggy/cms";
const floggy = createClient({ project: "projecta" });
// Blog index
const { posts, pageInfo } = await floggy.posts.list({ page: 1, perPage: 10 });
for (const post of posts) {
console.log(post.title, "/" + post.slug);
}
// Post page (rendered HTML + adjacent nav + SEO block + JSON-LD)
const post = await floggy.posts.get("hello-world", { format: "html" });
console.log(post.rendered); // ready-to-inject HTML string
console.log(post.adjacent.prev); // { slug, title } | null
const metadata = floggy.seo.meta(post); // plain Next.js-compatible Metadata objectDraft, preview, publish
// Needs an flg_ key with posts:write (and posts:read to preview). Server-side only.
const floggy = createClient({ project: "projecta", key: process.env.FLOGGY_KEY });
// Markdown in, draft out. `slug` comes from the title. Nothing is live yet.
const { id } = await floggy.posts.create({
title: "Launch Day",
excerpt: "We shipped.",
tagNames: ["Changelog"],
content: "## What changed\n\nWe shipped the new **editor**.",
});
// Preview by slug. Unpublished posts only: a published slug returns null.
const draft = await floggy.posts.draft("launch-day");
console.log(draft?.rendered);
await floggy.posts.publish(id); // now, preserving a backdated draft's date
await floggy.posts.unpublish(id); // back to draft, date intactAuthenticated calls act on the key owner's blog, not on project. project only selects which blog the public reads come from.
Next.js App Router example
// app/[slug]/page.tsx
import { createClient } from "@floggy/cms";
const floggy = createClient({ project: "projecta" });
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await floggy.posts.get(params.slug);
return floggy.seo.meta(post); // title, description, openGraph, twitter, alternates.canonical, robots
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await floggy.posts.get(params.slug, { format: "html" });
return (
<article>
<h1>{post.title}</h1>
{floggy.seo.jsonLd(post).map((node, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
/>
))}
<div dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />
</article>
);
}Drop-in feed routes
// app/sitemap.xml/route.ts
import { createClient } from "@floggy/cms";
const floggy = createClient({
project: "projecta",
site: { url: "blog.example.com", name: "Example", description: "My blog" },
});
export async function GET() {
return new Response(await floggy.feeds.sitemap(), {
headers: { "Content-Type": "application/xml" },
});
}The same pattern works for floggy.feeds.rss() (application/rss+xml) and floggy.feeds.robots() (text/plain).
Configuration
createClient({
project: "projecta", // required: the Floggy username/project
key: process.env.FLOGGY_KEY, // optional: flg_ key for drafts/private content
baseUrl: "https://floggy-api.pehcastro.workers.dev", // optional: API base URL
site: { // optional: used by feeds.*
url: "blog.example.com", // public origin (protocol auto-added)
name: "Example", // defaults to project name
description: "My blog", // defaults to "<name>'s blog"
locale: "en", // defaults to "en"
},
fetch: customFetch, // optional: custom fetch implementation
});Auth
Public reads need no key. Pass an flg_ key to read drafts and private content (posts:read), to create, update, and publish (posts:write), or to delete (posts:delete). Use a Read-only key in any frontend - a leaked read key only exposes content that is already public. Never embed an Editor or Full-access key in a frontend: it can publish and delete your posts.
const floggy = createClient({ project: "projecta", key: process.env.FLOGGY_KEY });Method reference
posts.list(options?) -> { posts, pageInfo }
Lists published posts. Always paginated.
| Option | Type | Default | Notes |
|--------|------|---------|-------|
| page | number | 1 | |
| perPage | number | 10 | Capped at 50 |
| tag | string | - | Filter by tag slug |
| sort | "publishedAt:desc" \| "publishedAt:asc" | publishedAt:desc | |
| format | "tiptap" \| "html" \| "markdown" | tiptap | |
pageInfo is { total, page, perPage, hasMore }.
posts.get(slug, options?) -> PostDetail
Fetches one post. Returns the post fields plus:
rendered- html/markdown string whenformatis nottiptapadjacent-{ prev, next }neighbouring postsseo- server-computed SEO blockjsonLd- array of JSON-LD nodes
Options: { format?: "tiptap" | "html" | "markdown" }.
posts.search(query, options?) -> Post[]
Full-text search across published posts. Options: { format? }.
Your own posts (needs a key)
| Method | Scope | Returns |
|--------|-------|---------|
| posts.mine(options?) | posts:read | Every post you own, drafts included. Unpaginated, full content on every row. |
| posts.getById(id, { format? }) | posts:read | One of your posts by id, draft or published. |
| posts.draft(slug, { format? }) | posts:read | One unpublished post by slug, or null. Returns DraftPost (PostDetail without seo/jsonLd). |
| posts.create(input) | posts:write | { id }. Defaults to a draft; content is Markdown unless you say otherwise. |
| posts.update(id, patch) | posts:write | void. tagNames replaces the whole set. |
| posts.publish(id, { at? }) | posts:write | void. Without at, reads first to preserve a backdated date. |
| posts.unpublish(id) | posts:write | void. publishedAt is left intact. |
| posts.delete(id) | posts:delete | void. |
| posts.bulk(action, ids) | posts:write | { success, affected }. Up to 100 ids, delete included. |
Full signatures, option tables, and the gotchas (bulk publish has no date guard, posts.draft costs two requests) are in the SDK reference.
markdownToTiptap(md) / tiptapToMarkdown(doc) / slugify(title)
Standalone, pure, dependency-free. markdownToTiptap is what turns a Markdown content string into the Tiptap document Floggy stores; slugify is the rule posts.create uses when you omit slug. The supported Markdown subset is documented in the SDK reference.
tags.list() -> TagWithCount[]
Returns [{ name, slug, count }], sorted by count.
seo.meta(post) -> Metadata
Reshapes a post's seo block into a plain object compatible with Next.js Metadata and TanStack head builders: title, description, keywords, alternates.canonical, robots, openGraph, twitter. Framework-agnostic, no framework import.
seo.jsonLd(post) -> JsonLd[]
Returns the post's JSON-LD nodes, ready to inject as <script type="application/ld+json">.
feeds.sitemap() -> Promise<string>
Fetches all published posts and returns a sitemap.xml string.
feeds.rss() -> Promise<string>
Returns an RSS 2.0 feed string (most recent 20 posts).
feeds.robots() -> string
Returns a robots.txt string pointing at the sitemap and feed. Synchronous.
Collections
Agent Collections are versioned, labelled structured content stored under your account. Every collections call requires an flg_ key with the matching collections:read / collections:write / collections:delete scope. Collections are scoped to the key's owner, so there is no project in these calls.
New to them? The collections quickstart walks the same flow end to end.
import { createClient } from "@floggy/cms";
const floggy = createClient({
project: "projecta",
key: process.env.FLOGGY_KEY, // needs collections:* scopes
});
// Create a collection
const collection = await floggy.collections.create({
name: "Changelog",
slug: "changelog",
schema: {
fields: [
{ name: "title", type: "string" },
{ name: "body", type: "richtext" },
],
indexes: { title: "title", slug: "slug" },
},
});
// Create an entry (a new version 1)
const entry = await floggy.collections.entries.create(collection.id, {
slug: "v1-0-0",
data: { title: "1.0.0", body: "First release." },
tags: ["release"],
});
// Publish it: points the "production" label at that version
await floggy.collections.publish(collection.id, entry.id, entry.version);
// List entries (light rows; pass full: true to include data)
const { entries, pageInfo } = await floggy.collections.entries.list(collection.id, {
perPage: 50,
tag: "release",
});
// Read the published entry (defaults to the production version)
const published = await floggy.collections.entries.get(collection.id, entry.id);Optimistic concurrency
Pass ifVersion on an update to reject a write that raced another change. A mismatch throws ConflictError carrying the server's currentVersion.
import { ConflictError } from "@floggy/cms";
try {
await floggy.collections.entries.update(collection.id, entry.id, {
data: { title: "1.0.1" },
ifVersion: entry.version,
});
} catch (err) {
if (err instanceof ConflictError) {
console.log("stale write, current version is", err.currentVersion);
}
}A/B testing with labels
Create one label per variant, then resolve each visitor to a stable variant with pick. It is a pure function: the same visitorId always maps to the same label, and per-label meta.weight controls the traffic split.
import { pick } from "@floggy/cms";
// Point two labels at two versions, weighted 3:1
await floggy.collections.labels.set(collection.id, entry.id, "hero-a", { version: 4, meta: { weight: 3 } });
await floggy.collections.labels.set(collection.id, entry.id, "hero-b", { version: 5, meta: { weight: 1 } });
const detail = await floggy.collections.entries.get(collection.id, entry.id);
const variant = pick(detail.labels, visitorId, { exclude: ["production"] });
const shown = await floggy.collections.entries.get(collection.id, entry.id, {
label: variant ?? "production",
});Building a sitemap from entries
sitemapFromEntries turns any list of items with a slug into a sitemap.xml string.
import { sitemapFromEntries } from "@floggy/cms";
const { entries } = await floggy.collections.entries.list(collection.id, { perPage: 50 });
const xml = sitemapFromEntries(entries, {
baseUrl: "blog.example.com",
path: (e) => `/changelog/${e.slug}`,
});Method reference
collections.list()/create(input)/get(id)/update(id, input)/delete(id)collections.entries.list(id, options?)->{ entries, pageInfo }(perPagecapped at 50;fullhydratesdata)collections.entries.get(id, entryId, { label?, version? })->EntryDetailcollections.entries.create(id, { slug?, data, tags?, label? })(413 whendata> 256KB). Entries are drafts unless you passlabel: "production", which publishes in the same call. Requires@floggy/cms>= 0.2.1collections.entries.update(id, entryId, { data?, tags?, slug?, ifVersion? })(409 onifVersionmismatch)collections.entries.delete(id, entryId)collections.entries.bulk(id, operation)->{ success, affected, errors }(max 50 items)collections.versions.list(id, entryId)/get(id, entryId, version)collections.labels.list(id, entryId)/set(id, entryId, name, { version, meta? })/remove(id, entryId, name)collections.publish(id, entryId, version)= set the "production" label
Errors
Every non-2xx response throws a typed error.
import { FloggyError, RateLimitError, ScopeError } from "@floggy/cms";
try {
await floggy.posts.publish(id);
} catch (err) {
if (err instanceof ScopeError) {
console.log("this key needs:", err.required.join(", "));
} else if (err instanceof RateLimitError) {
console.log("retry after", err.retryAfter, "seconds");
} else if (err instanceof FloggyError) {
console.log(err.status, err.message, err.code);
}
}FloggyError carries status, message, code, and url. ScopeError (status 403) adds required, the scopes the endpoint asked for, so you can tell the key's owner exactly what to add. RateLimitError (status 429) adds retryAfter (seconds, parsed from the Retry-After header). ConflictError (status 409) is an optimistic-concurrency failure only, from an entry ifVersion mismatch, and adds currentVersion. A post slug that is already in use is also a 409 but stays a plain FloggyError with the message "Slug already used": catch it, pick another slug, retry. An oversized entry (data over 256KB) surfaces as a plain FloggyError with status 413.
Documentation
Full docs live at floggy.xyz/docs/cms:
- Quickstart - list, fetch, render, and add SEO in about 15 lines.
- SDK reference - every
@floggy/cmsmethod, with Next.js and TanStack Start examples. - API reference - the underlying REST endpoints (public reads + admin writes), for curl and non-SDK use.
- Authentication - creating
flg_keys, the scope table, presets, and key-safety rules. - Webhooks - subscribe to
post.published/post.updatedand verify signatures.
Collections have their own set:
- Collections overview - what they are and when to reach for them.
- Collections quickstart - schema to published entry, end to end.
- Schemas - field types and the three indexable columns.
- Collections API - the REST surface, for curl and non-SDK use.
- Versioning and labels - drafts, publishing, and rollback.
- A/B testing - variants as labels, with your own analytics.
- SEO content - generated landing copy, reviewed before it goes live.
- Collections CLI - pull, push, promote from the terminal.
If you are a coding agent, start here: floggy.xyz/docs/llms.txt - the whole documentation set in one plain-text file.
Looking for the terminal client instead? That is @floggy/cli.
License
MIT
