OpinlySDK

Core concepts

How content flows from Opinly to your app: resolve, render, and SEO — and which package does what.

The SDK has three jobs: fetch content, render it, and describe it for search engines. Each is handled by a different layer so you can adopt only what you need.

Content is Tiptap JSON

Your posts are stored as structured Tiptap (ProseMirror) JSON — not HTML, not Markdown. A document is a tree of typed nodes (paragraph, heading, image, bulletList, table, …) with inline marks (bold, link, code, …). The SDK calls this shape OpinlyNode.

Structured JSON means you control exactly how each node renders — wrap headings, lazy-load images, restyle quotes — without parsing HTML strings. Images are pre-resolved server-side to a stable fileKey that maps to your CDN, so the client never deals with raw upload IDs.

Taxonomy prefixes

Categories and authors live under their own URL prefix/blog/category/... and /blog/authors/.... Slugs come from Opinly; the prefix (the URL shape) is yours, set via OpinlyConfig's categoryPrefix (default category) and authorPrefix (default authors). In Next.js you set them on withOpinlyConfig; the SDK's URL builders, buildMetadata, sitemap and JSON-LD all emit URLs that match. Keep them next to your routing so the two can't drift.

Post permalinks are flat — a post is /blog/<post>, addressed by a single company-unique slug (never nested under its category). The prefix applies to the category/author archive pages only (the same way WordPress's "category base" works).

Routing: prefix → typed endpoint

Because the taxonomy is prefixed, the URL itself tells you what a route is — you route by the first segment, no server round-trip to disambiguate. Each branch maps to one typed call:

// catch-all handler, with `slug: string[]` and your configured prefixes
if (slug.length === 0) {
  const { data: posts } = await opinly.posts({ limit: 12 })   // the blog index
  const categories = await opinly.categories()
} else if (slug[0] === categoryPrefix) {
  const { data: posts } = await opinly.posts({ category: slug[1] })  // a category archive
} else if (slug[0] === authorPrefix) {
  slug[1]
    ? await opinly.author(slug[1])                  // one author + their posts
    : await opinly.authors()                        // the authors directory
} else if (slug.length === 1) {
  const post = await opinly.post(slug[0])           // a single post by its flat slug (or null)
}

A category page is just posts({ category }) — cursor-paginated, no separate "category resolve". post(slug) fetches one post by its flat, company-unique slug and returns the FullPost, or null if nothing's there.

Lists are cursor-paginated: posts() returns { data, has_more, next_cursor }; pass next_cursor back as cursor for the next page.

Rendering: agnostic core + framework renderer

@opinly/shared walks the OpinlyNode tree and produces output. It's pure and framework-free, exposed two ways:

  • renderToHtml(content, { config }) → an HTML string (great for RSS, email, or SSR where you just want markup).
  • createRenderer({ renderFn, config }) → a generic walker that builds your framework's elements. The framework packages wrap this for you:
You writeUnder the hood
<OpinlyContent> from @opinly/reactcreateRenderer({ renderFn: React.createElement })
<OpinlyContent> from @opinly/vuecreateRenderer({ renderFn: h })
<OpinlyContent> from @opinly/svelterenderToHtml(...) via {@html}

You never call the renderer directly unless you want to — the components are the public surface.

Config

Every render/SEO call takes an OpinlyConfig:

interface OpinlyConfig {
  imagesPrefix: string   // where images resolve, e.g. "/images"
  siteUrl?: string       // "https://example.com" (for canonical URLs + JSON-LD)
  blogPrefix?: string    // "/blog"
  siteName?: string      // "Acme Blog"
}

In Next.js, @opinly/next populates this from the env vars withOpinlyConfig injects, so you read it from opinlyConfig. In Nuxt/SvelteKit you pass the object directly.

SEO

@opinly/shared turns a resolved route into neutral metadata (buildMetadata) and schema.org JSON-LD (buildBlogPostingJsonLd, buildFaqJsonLd, …). The meta-adapters reshape that into your framework's head API:

  • Next.jsgenerateOpinlyMetadata() returns a Next Metadata object; OpinlyJsonLd renders the <script type="application/ld+json">.
  • NuxtopinlyHead() returns a useHead() payload.
  • SvelteKit<OpinlySeo> writes into <svelte:head>.

See the SEO reference for every builder.

You own the UI

The SDK renders the post body and gives you data + SEO. Everything else — page layout, cards, navigation, hero, CTA — is yours. There is no bundled stylesheet and no <OpinlyBlog> mega-component. Style the rendered body however you like (Tailwind prose, the classNames prop, or custom node components). This is what "headless" means here.