SDK reference

@floggy/cms is a thin, fully typed client over the Floggy API: it reads your published blog without a key, and with an flg_ key it also reads your drafts and writes posts (create, update, publish, delete). The Floggy server renders your content (html, markdown, SEO, JSON-LD, feeds); the SDK fetches, types, and converts Markdown for you. Zero runtime dependencies - it uses the platform fetch.

createClient(config)

import { createClient } from "@floggy/cms";

const floggy = createClient({
  project: "projecta",                 // required: the Floggy username / project
  key: process.env.FLOGGY_API_KEY,     // optional: flg_ key for drafts / private content / writes
  site: {                              // optional: used by feeds.*
    url: "blog.example.com",           // public origin; protocol auto-added if missing
    name: "Example",                   // defaults to the project name
    description: "My blog",            // defaults to "<name>'s blog"
    locale: "en",                      // defaults to "en"
  },
  fetch: customFetch,                  // optional: custom fetch implementation
});

project is required. key is only needed for drafts, private content, and writes; leave it out for a purely public blog. Returns a FloggyClient with five namespaces: posts, tags, seo, feeds, and collections.

project scopes reads, the key scopes writes

project selects the blog that the public calls read: posts.list, posts.get, posts.search, tags.list, feeds.*.

Everything authenticated ignores project and answers as the API key's owner: posts.mine, posts.getById, posts.draft, every write, and all of collections. Point a client at someone else's project with your own key and you will read their blog while writing to yours. Keep project and the key on the same account unless you deliberately want that split.

posts (public reads)

posts.list(options?) -> Promise<{ posts, pageInfo }>

Lists published posts. Always paginated (the SDK always sends page/perPage, so you always get the envelope).

Option Type Default
page number 1
perPage number 10 (capped at 50)
tag string -
sort "publishedAt:desc" | "publishedAt:asc" "publishedAt:desc"
format "tiptap" | "html" | "markdown" "tiptap"

pageInfo is { total, page, perPage, hasMore }.

posts.get(slug, options?) -> Promise<PostDetail>

Fetches one post. PostDetail is the post fields plus:

  • rendered - html/markdown string when format is not tiptap, else null
  • content - always the raw Tiptap JSON
  • adjacent - { prev, next }, each { slug, title } | null
  • seo - server-computed SEO block
  • jsonLd - array of JSON-LD nodes

Options: { format?: "tiptap" | "html" | "markdown" }.

posts.search(query, options?) -> Promise<Post[]>

Full-text search across published posts (title + excerpt). Returns a flat array. Options: { format? }.

posts (your own posts)

Everything below needs an flg_ key and acts on the key owner's blog. Scopes come from the key: posts:read to read, posts:write to create/update/publish, posts:delete to delete. A key missing one throws a ScopeError.

posts.mine(options?) -> Promise<OwnPost[]>

Every post you own, drafts included, newest-created first. Requires posts:read.

Option Type Notes
status "draft" | "published" Filters the array client-side.

status is the only option. The endpoint takes no query parameters, so there is no format here: OwnPost is the stored row, not the reader's view, with no seo, jsonLd, adjacent, or author join, and content always raw Tiptap. Use getById when you need html or markdown.

The underlying endpoint takes no query parameters, so this is unpaginated and returns the full content of every post in one response. On a large blog that is a heavy call. Cache it if you are going to reach for it repeatedly.

const drafts = await floggy.posts.mine({ status: "draft" });
console.log(drafts.map((post) => post.slug));

posts.getById(id, options?) -> Promise<OwnPostDetail>

One of your posts by id, draft or published. Requires posts:read. An id you do not own is a FloggyError with status 404.

Options: { format?: "tiptap" | "html" | "markdown" }. OwnPostDetail adds format and rendered to OwnPost; rendered is null for the default tiptap.

const post = await floggy.posts.getById(id, { format: "html" });
console.log(post.rendered);

posts.draft(slug, options?) -> Promise<DraftPost | null>

One unpublished post by slug, or null. This is the primitive behind a /drafts/<slug> preview route: an agent creates a draft, you review it on your own site, and only then do you publish. Requires posts:read.

Option Type Default
format "tiptap" | "html" | "markdown" "html"

Things to know before you build on it:

  • It returns null for a published post too, not just a missing one. A published post already has a URL of its own, and a drafts route exists to show what is not live yet.
  • It costs two requests: mine() resolves the slug to an id (the by-id endpoint is id-only), then the post is fetched. That means it pays the full cost of mine() on every call.
  • The return type is DraftPost, exported as Omit<PostDetail, "seo" | "jsonLd">. adjacent is present and filled with { prev: null, next: null }, so preview components that expect it keep working, but the two server-computed blocks are gone: the endpoint that can see a draft does not produce them. seo.meta() and seo.jsonLd() take a PostDetail, so passing a draft to either is a type error rather than something you discover at runtime. Build a draft page's metadata from the post's own fields, and keep the page noindex regardless.
  • A revoked key, a missing scope, or a network failure throws. Only a genuinely absent or already-published post is null, so a bad key never looks like a missing post.

posts.create(input) -> Promise<{ id: string }>

Creates a post. Defaults to a draft, so an agent can write without anything going live. Requires posts:write. Returns the new post's id.

Field Type Notes
title string Required.
slug string ^[a-z0-9-]+$. Derived from the title with slugify when omitted.
content string | TiptapJSON Markdown string by default; a Tiptap object is passed through.
contentFormat "markdown" | "tiptap" Defaults to "markdown". Use "tiptap" when content is a JSON-encoded Tiptap document.
excerpt string
coverImage string Image URL.
subtitle string | null
featured boolean
visibility "public" | "private" | "link-only" Server default "public". "link-only" is unlisted: reachable by URL, absent from the index, feeds, and sitemap.
status "draft" | "published" The SDK sends "draft" when you omit it.
tagNames string[] By display name. Missing tags are created on your account.
publishedAt string | Date | null A clearly future date schedules the post.
coauthors { userId }[] | { externalName, externalHandle?, externalUrl? }[] A userId lands as a pending invite; an external name is auto-accepted.
SEO overrides string | null seoTitle, seoDescription, canonicalUrl, ogTitle, ogDescription, ogImage, twitterTitle, twitterDescription, twitterImage, robots.
const { id } = await floggy.posts.create({
  title: "Launch Day",
  excerpt: "We shipped.",
  tagNames: ["Changelog"],
  content: `## What changed

We shipped the new editor. Read the [notes](https://example.com/notes).

- Faster paste
- Fewer crashes`,
});

A slug already in use on your account is a plain FloggyError with status: 409 and the message "Slug already used". Catch it, pick another slug, and retry:

import { FloggyError, slugify } from "@floggy/cms";

async function createWithUniqueSlug(input: { title: string; content?: string }) {
  const base = slugify(input.title);
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return await floggy.posts.create({
        ...input,
        slug: attempt === 0 ? base : `${base}-${attempt + 1}`,
      });
    } catch (err) {
      if (err instanceof FloggyError && err.status === 409) continue;
      throw err;
    }
  }
  throw new Error("Could not find a free slug.");
}

A slug that collides with a reserved Floggy path is a 400, not a 409, and retrying with a suffix will not help.

posts.update(id, patch) -> Promise<void>

Patches a post. Send only what changes; anything you omit is left alone. Requires posts:write. Takes the same fields as create, all optional.

Two fields do not merge:

  • tagNames replaces the post's tags wholesale. [] clears them.
  • coauthors replaces the co-authors you added (other people's invites survive).

publishedAt: null clears the stored date. Calling update with an empty patch throws before any request is made.

await floggy.posts.update(id, {
  content: "# Launch Day\n\nRewritten body.",
  tagNames: ["Changelog", "Product"],
  seoDescription: "What shipped on launch day.",
});

posts.publish(id, options?) -> Promise<void>

Sets status to published. Requires posts:write.

Option Type Notes
at string | Date The publication date. A clearly future date schedules the post instead of publishing it.

Without at, this costs an extra GET. The server stamps publishedAt to now the first time a post goes live, which would silently discard a backdated draft's date, so the SDK reads the current value and re-sends it. Pass at to skip that round trip and be explicit. If the read fails, the publish still goes through and the server dates it.

The server clamps a publishedAt that is in the past or within about five minutes of now down to server time, so the post is live immediately. Dates further out stay scheduled.

await floggy.posts.publish(id);                              // now, preserving a backdated draft
await floggy.posts.publish(id, { at: "2026-01-15T09:00:00Z" }); // backdate
await floggy.posts.publish(id, { at: new Date(Date.now() + 864e5) }); // schedule for tomorrow

posts.unpublish(id) -> Promise<void>

Sets status back to draft. publishedAt is left intact, so publishing again restores the original date. Requires posts:write.

posts.delete(id) -> Promise<void>

Deletes a post permanently. Requires posts:delete, which is a separate scope from posts:write.

posts.bulk(action, ids) -> Promise<{ success, affected }>

Publishes, unpublishes, or deletes up to 100 posts in one call. action is "publish" | "unpublish" | "delete".

const { affected } = await floggy.posts.bulk("unpublish", [id1, id2]);

Four things this does not share with the single-post methods:

  • All three actions need only posts:write, including "delete". posts.delete needs posts:delete, posts.bulk("delete", ...) does not.
  • "publish" has no date guard. The server stamps publishedAt to now on every post that was not already published. Publish backdated drafts one at a time with posts.publish.
  • Ids you do not own are skipped, so affected can be lower than what you sent. If none of them match, the server answers 404.
  • Bulk operations do not fire webhooks. Use the single-post methods when you need a post.published or post.updated delivery.

Passing zero ids or more than 100 throws before the request.

Markdown

Floggy stores post bodies as Tiptap (ProseMirror) JSON. These two functions are the bridge, and they are why posts.create accepts a plain string. Both are pure, dependency-free, and never throw.

markdownToTiptap(md) -> TiptapJSON

Parses a Markdown string into a Tiptap document. This runs automatically on a string content in create/update; call it yourself when you want to inspect or post-process the document first.

tiptapToMarkdown(doc) -> string

Renders a Tiptap document back to Markdown. A value that is not a document returns "".

import { markdownToTiptap, tiptapToMarkdown } from "@floggy/cms";

const post = await floggy.posts.getById(id);
const md = tiptapToMarkdown(post.content);
await floggy.posts.update(id, { content: markdownToTiptap(md.replace("typo", "fix")) });

The supported subset

The parser covers the subset Floggy's editor renders:

  • Blocks: headings (# to ######), paragraphs, bullet and ordered lists (nested by 2 spaces), blockquotes, fenced code with a language, horizontal rules, and a standalone image line.
  • Inline: bold, italic, code, strike, links, images, hard breaks (two trailing spaces), and backslash escapes.

Not parsed on input, and degraded to plain paragraph text: tables, embeds, video, audio, GitHub cards, underline, highlight, subscript and superscript, text alignment, setext headings, reference links, bare autolinks, raw HTML, task lists, and footnotes.

Those node types still exist in Floggy and survive a format: "tiptap" read untouched, so reading and re-sending raw content is lossless. It is tiptapToMarkdown that flattens them: they keep their text and lose their structure. Round-trip through Markdown only when you are prepared for that.

Floggy's image extension is block-level, so put an image on its own line. An image inside a sentence parses, but it renders as its own block.

slugify(title) -> string

The exact rule posts.create uses when you omit slug: lowercased, quotes dropped, every other non-alphanumeric run collapsed to a dash, trimmed to 80 characters. Empty input returns "post".

import { slugify } from "@floggy/cms";

slugify("Launch Day: what's new"); // "launch-day-whats-new"

tags

tags.list() -> Promise<TagWithCount[]>

Returns [{ name, slug, count }], sorted by count descending.

seo

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. Synchronous.

Only a post from posts.get carries that block. A DraftPost from posts.draft and an OwnPostDetail from posts.getById do not, and neither one typechecks as an argument here.

seo.jsonLd(post) -> JsonLd[]

Returns the post's JSON-LD nodes, ready to inject as <script type="application/ld+json">. Synchronous. Returns [] for a post that has none, including drafts.

feeds

Driven by site in the client config.

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.

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);
  }
}
Error Status Adds
FloggyError any non-2xx status, message, code, url
ScopeError 403 required: the scopes the endpoint asked for
RateLimitError 429 retryAfter in seconds, parsed from the Retry-After header
ConflictError 409 currentVersion, on a collections ifVersion mismatch only

ScopeError is thrown only when the 403 body names the scopes it wanted; any other 403 is a plain FloggyError. err.required is the endpoint's full requirement list, which is what you tell the key's owner to add:

catch (err) {
  if (err instanceof ScopeError) {
    throw new Error(
      `Your Floggy key cannot do this. Regenerate it in Settings > Developer with: ${err.required.join(", ")}`,
    );
  }
  throw err;
}

ConflictError means an optimistic-concurrency failure and nothing else: a collections ifVersion mismatch, where re-reading and rebasing is the fix. A slug already in use on create or update is also a 409, but it is a plain FloggyError, because the fix there is a different slug, not a rebase. Catch FloggyError and check err.status === 409 for slug collisions; catch ConflictError for version conflicts.

Bad arguments are caught before the request and throw a plain Error: an empty title, a slug outside ^[a-z0-9-]+$, an unparseable date, an empty update patch, an empty or oversized bulk list.

Next.js (App Router)

A complete post page with metadata, rendered HTML, and JSON-LD.

// app/[slug]/page.tsx
import { createClient } from "@floggy/cms";

const floggy = createClient({ project: "projecta" });

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await floggy.posts.get(slug);
  return floggy.seo.meta(post); // title, description, openGraph, twitter, alternates.canonical, robots
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await floggy.posts.get(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>
  );
}

Draft preview route

A /drafts/<slug> page that renders a post that is not live yet. This is the route behind "an agent wrote something, look at it on the real site before it ships".

// app/drafts/[slug]/page.tsx
import { cache } from "react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { createClient } from "@floggy/cms";

// Server-only: this client carries a private key. Never import it from a
// client component.
const floggy = createClient({
  project: "projecta",
  key: process.env.FLOGGY_API_KEY, // needs posts:read
});

// A draft is looked at precisely because it is still changing.
export const dynamic = "force-dynamic";

// generateMetadata and the page both need the post, and each draft() call
// costs two requests. cache() collapses them into one fetch per render.
const getDraft = cache((slug: string) => floggy.posts.draft(slug));

type Params = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Params): Promise<Metadata> {
  const { slug } = await params;
  const post = await getDraft(slug);

  // A draft carries no `seo` block, so seo.meta() is not an option here.
  // Build the metadata from the post's own fields.
  return {
    title: post ? `Draft: ${post.title}` : "Draft",
    description: post?.excerpt ?? undefined,
    robots: { index: false, follow: false },
  };
}

export default async function DraftPage({ params }: Params) {
  const { slug } = await params;
  const post = await getDraft(slug);
  if (!post) notFound(); // unknown slug, or already published

  return (
    <article>
      <p>Draft preview. Not indexed, not linked, not live.</p>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />
    </article>
  );
}

The link is the access control, so close off discovery: noindex, nofollow on every response including the 404, Disallow: /drafts in robots.txt, keep the route out of your sitemap, and never link to it. The route answers 404 for an unknown slug and for a published one, so it never confirms that a slug exists.

Publishing from the same client is one call once the preview looks right:

const post = await floggy.posts.draft("launch-day");
if (post) await floggy.posts.publish(post.id);

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" },
  });
}
// app/rss.xml/route.ts
import { floggy } from "@/lib/floggy"; // a shared client with `site` set

export async function GET() {
  return new Response(await floggy.feeds.rss(), {
    headers: { "Content-Type": "application/rss+xml" },
  });
}
// app/robots.txt/route.ts
import { floggy } from "@/lib/floggy";

export function GET() {
  return new Response(floggy.feeds.robots(), {
    headers: { "Content-Type": "text/plain" },
  });
}

Reuse one client across routes by exporting it from lib/floggy.ts with site configured, so the feed builders know your public origin.

TanStack Start

seo.meta returns a plain object, so it slots straight into a route's head.

// src/routes/$slug.tsx
import { createFileRoute } from "@tanstack/react-start";
import { createClient } from "@floggy/cms";

const floggy = createClient({ project: "projecta" });

export const Route = createFileRoute("/$slug")({
  loader: ({ params }) => floggy.posts.get(params.slug, { format: "html" }),
  head: ({ loaderData: post }) => {
    const meta = floggy.seo.meta(post);
    return {
      meta: [
        { title: meta.title },
        { name: "description", content: meta.description },
        { property: "og:title", content: meta.openGraph.title },
        { property: "og:image", content: meta.openGraph.images[0]?.url },
      ],
      links: [{ rel: "canonical", href: meta.alternates.canonical }],
    };
  },
  component: Post,
});

function Post() {
  const post = Route.useLoaderData();
  return <article dangerouslySetInnerHTML={{ __html: post.rendered ?? "" }} />;
}

Feed routes work the same way: a server route handler that returns await floggy.feeds.sitemap() / rss() / floggy.feeds.robots() with the right Content-Type.