@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.
Pass your own fetch to control caching. On Next.js, tag the responses: cache: 'force-cache'
is what puts them in the data cache, and the tags are what let a webhook invalidate exactly what
changed (see Webhooks).
// Next.js: cache responses under one tag so a webhook can drop them all
const opinly = createOpinlyClient({
fetch: (url, init) =>
fetch(url, { ...init, cache: 'force-cache', next: { tags: ['opinly'] } }),
})Tags beat path-based invalidation here: revalidateTag works whether the route rendering the data
is static or dynamic, while revalidatePath silently does nothing for dynamic routes on a
self-hosted/OpenNext deployment. Bust them with revalidateTag('opinly', { expire: 0 }) — on Next
16+ the second argument is required, and { expire: 0 } is the immediate drop you want on a publish
webhook (see Webhooks).
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 exports the webhook event type for content changes:
import type { OpinlyWebhookEvent, ContentRouteChange } from '@opinly/backend'
// { type: 'content.routes-changed'; data: { changed: ContentRouteChange[] } }Each ContentRouteChange is { type, slug, lastModified } — the same shape as an entry from
routes() — so you map them onto your own routing. Tag changes are included, which covers a change
to a post's tag membership (that invalidates the post's own page too).
See Webhooks for the full handler.