@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).
| Method | Returns | Description |
|---|---|---|
posts({ limit?, cursor?, category?, author?, sort? }) | PostList | A cursor-paginated page of published posts. Filter by category/author slug. |
post(slug) | FullPost | null | A single post by its flat, company-unique slug (string, e.g. 'my-post'); null if none. |
author(slug) | AuthorPage | A single author page (or not-found). |
authors() | Authors | All 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[] } } ← legacyPrefer 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.