OpinlySDK

@opinly/backend

The typed data client — createOpinlyClient and every method it returns.

@opinly/backend wraps the /v1 REST API in a small, fully-typed client. Its TypeScript types are generated from the OpenAPI spec, so they always match the API.

createOpinlyClient

import { createOpinlyClient } from '@opinly/backend'

const opinly = createOpinlyClient({
  apiKey?: string,            // defaults to process.env.OPINLY_API_KEY
  url?: string,               // defaults to "https://sdk.opinly.ai"
  fetch?: typeof fetch,       // inject a custom fetch (e.g. for caching)
})

The key is sent as Authorization: Bearer <key> on every request. If no apiKey is provided and OPINLY_API_KEY is not set, the call throws — so always create the client server-side.

// Next.js: cache responses, invalidate via webhooks
const opinly = createOpinlyClient({
  fetch: (url, init) => fetch(url, { ...init, cache: 'force-cache' }),
})

Methods

Each method maps to one endpoint and returns a typed result. There is no resolve() — route by URL in your app and call the matching method. Categories and authors are taxonomy-prefixed, so a category archive is posts({ category }) and a single post is post(slug).

MethodReturnsDescription
posts({ limit?, cursor?, category?, author?, sort? })PostListA cursor-paginated page of published posts. Filter by category/author slug.
post(slug)FullPost | nullA single post by its flat, company-unique slug (string, e.g. 'my-post'); null if none.
author(slug)AuthorPageA single author page (or not-found).
authors()AuthorsAll authors with sample posts.
categories()CategorySummary[]Categories, each with up to 5 latest posts.
tags()TagSummary[]Topic tags, each with a count of its published posts.
routes()ContentRoute[]Every addressable route — { type, slug, lastModified } (bare slugs). Feeds both your sitemap and static generation; shape each with sitemapUrl/routeParams from @opinly/shared.
rss({ limit? })RssItem[]Feed items ({ slug, title, description?, date, categories? }).
const first = await opinly.posts({ limit: 12 })
const next  = await opinly.posts({ cursor: first.next_cursor ?? undefined })
const post  = await opinly.post('my-post') // FullPost | null
const feed  = await opinly.rss({ limit: 50 })

if (post) {
  // post.content is your Tiptap JSON
}

post() returns null on a 404; every other method throws on a non-2xx response, and the thrown Error message includes the problem code and detail from the API (see Errors).

All domain types (FullPost, Post, CategorySummary, AuthorPage, Authors, PostList, ContentRoute, RssItem, ContentNode, Problem, …) are exported from the package for use in your own components:

import type { FullPost, Post } from '@opinly/backend'

Webhook types

The package also exports the webhook event type for content changes. It's a union, so narrow on type. Post, category and author changes emit both events; tag changes emit only content.routes-changed — including a change to a post's tag membership, which invalidates that post's page too, so a legacy-only consumer won't see it:

import type { OpinlyWebhookEvent, ContentRouteChange } from '@opinly/backend'

// | { type: 'content.routes-changed';    data: { changed: ContentRouteChange[] } }  ← recommended
// | { type: 'content.paths-invalidated'; data: { paths: string[] } }                ← legacy

Prefer content.routes-changed: it gives you structured entities ({ type, slug, lastModified }) that you map onto your own routing, which is the right fit for the flat-slug v1 SDK. content.paths-invalidated carries pre-built nested /{category}/{post} strings and is kept for older integrations.

See Webhooks for the full handler.