OpinlySDK

Next.js

Add an Opinly blog to a Next.js App Router project — fetching, rendering, metadata, JSON-LD, sitemap and RSS.

This guide builds a complete blog on the Next.js App Router. You own the page UI; the SDK provides data (@opinly/backend), the content renderer (@opinly/react), and the Next.js glue (@opinly/next).

Install

pnpm add @opinly/backend @opinly/react @opinly/next
npm i @opinly/backend @opinly/react @opinly/next
yarn add @opinly/backend @opinly/react @opinly/next

Configure next.config

withOpinlyConfig injects the SDK's environment variables and adds an image rewrite so your post images are served from the Opinly CDN under your own domain.

// next.config.ts
import type { NextConfig } from 'next'
import { withOpinlyConfig } from '@opinly/next'

const nextConfig: NextConfig = {
  // ...your existing config
}

export default withOpinlyConfig({
  blogPath: '/blog',                  // where your blog lives (must match your route)
  imagesPath: '/images',             // local path images are rewritten from
  companyName: 'Acme',               // used in metadata
  cdnNamespace: 'REPLACE-ME-xxxxxxxx', // 21 chars, from Settings → Developers
  siteUrl: 'https://acme.com',       // no trailing slash
})(nextConfig)
OptionNotes
blogPathMust match the route segment your blog renders under (e.g. /blog).
imagesPathLocal prefix rewritten to the CDN. Must not collide with your own assets.
cdnNamespaceExactly 21 characters. From Settings → Developers.
siteUrlAbsolute site URL, used for canonical URLs and JSON-LD.
categoryPrefix?URL segment category archives live under, relative to blogPath. Default "category" (/blog/category/nutrition); set "" for bare /blog/nutrition. Applies to archives only — post permalinks are never prefixed. Must match your routing.
authorPrefix?URL segment authors live under, relative to blogPath. Default: "authors" (/blog/authors/jane).
unoptimizedImages?Set true to skip Next image optimization.

Add your API key to the environment:

# .env
OPINLY_API_KEY="sk-…"

Create the client

// clients/opinly.ts
import { createOpinlyClient } from '@opinly/backend'

// Picks up OPINLY_API_KEY from the env. `force-cache` makes content static and
// fast; you invalidate it with webhooks (see Operations → Webhooks).
export const opinly = createOpinlyClient({
  fetch: (url, init) => fetch(url, { ...init, cache: 'force-cache' }),
})

The blog route

A single optional-catch-all route renders every blog page. Because the taxonomy is prefixed, you route by the URL's first segment to the matching typed endpoint — a category archive is just posts({ category }), and post(slug) fetches the single post. A small loadRoute helper keeps the page and generateMetadata in sync (Next dedupes the cached fetches, so calling it twice is free).

// app/blog/[[...slug]]/page.tsx
import type { ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
import { generateOpinlyMetadata, opinlyConfig } from '@opinly/next'
import type { SeoResolved } from '@opinly/shared'
import { opinly } from '@/clients/opinly'

export const revalidate = 3600

const categoryPrefix = opinlyConfig.categoryPrefix ?? 'category'
const authorPrefix = opinlyConfig.authorPrefix ?? 'authors'

type BlogPageProps = { params: Promise<{ slug?: string[] }> }

const loadRoute = async (slug: string[]) => {
  if (slug.length === 0) {
    const [posts, categories] = await Promise.all([opinly.posts({ limit: 12 }), opinly.categories()])
    return { type: 'home' as const, data: { posts: posts.data, categories } }
  }
  if (slug[0] === categoryPrefix && slug[1]) {
    const [categories, list] = await Promise.all([opinly.categories(), opinly.posts({ category: slug[1] })])
    const meta = categories.find((c) => c.slug === slug[1])
    if (!meta) return { type: 'not-found' as const }
    return { type: 'category' as const, data: { ...meta, name: meta.title, posts: list.data } }
  }
  if (slug[0] === authorPrefix) {
    const authorSlug = slug[1]
    if (!authorSlug) return { type: 'authors' as const, data: (await opinly.authors()).data }
    const author = await opinly.author(authorSlug)
    return author.type === 'author'
      ? { type: 'author' as const, data: author.data }
      : { type: 'not-found' as const }
  }
  // Posts are flat: a single-segment slug. Anything deeper isn't a post route.
  if (slug.length !== 1) return { type: 'not-found' as const }
  const post = await opinly.post(slug[0]) // a single post by its flat slug, or null
  return post
    ? { type: 'post' as const, data: post }
    : { type: 'not-found' as const }
}

// Map the route to the neutral SeoResolved buildMetadata understands.
const toSeo = (route: Awaited<ReturnType<typeof loadRoute>>): SeoResolved =>
  route.type === 'post' || route.type === 'category' || route.type === 'author'
    ? { type: route.type, data: route.data }
    : { type: route.type }

export const generateMetadata = async (props: BlogPageProps, parent: ResolvingMetadata) => {
  const { slug } = await props.params
  return generateOpinlyMetadata(toSeo(await loadRoute(slug ?? [])), parent)
}

export default async function BlogPage(props: BlogPageProps) {
  const { slug } = await props.params
  const route = await loadRoute(slug ?? [])

  switch (route.type) {
    case 'home':     return <BlogIndex data={route.data} />
    case 'post':     return <BlogPost post={route.data} />
    case 'category': return <CategoryView category={route.data} />
    case 'author':   return <AuthorView author={route.data} />
    case 'authors':  return <AuthorsView authors={route.data} />
    default:         notFound()
  }
}

generateOpinlyMetadata takes the already-resolved data (not the client), so there's no second fetch in generateMetadata.

Render the post body

Use <OpinlyContent> from @opinly/react. Derive the render config from opinlyConfig (which @opinly/next populates from the env vars you configured above), so images resolve correctly.

// components/post-content.tsx
import { OpinlyContent } from '@opinly/react'
import { opinlyConfig } from '@opinly/next'
import type { OpinlyNode } from '@opinly/shared'

const config = {
  imagesPrefix: opinlyConfig.imagesPrefix,
  siteUrl: opinlyConfig.siteUrl,
  blogPrefix: opinlyConfig.blogPrefix,
  siteName: opinlyConfig.siteName,
}

export function PostContent({ content }: { content: OpinlyNode }) {
  return (
    <div className="prose prose-lg max-w-none">
      <OpinlyContent content={content} config={config} />
    </div>
  )
}

To swap in next/image or next/link for specific nodes, pass components — see Rendering.

Structured data (JSON-LD)

import { OpinlyJsonLd, buildBlogPostingJsonLd, buildFaqJsonLd } from '@opinly/next'

// inside your post component, with the resolved FullPost:
<OpinlyJsonLd data={buildBlogPostingJsonLd(post)} />
{post.faqs?.length ? <OpinlyJsonLd data={buildFaqJsonLd(post.faqs)} /> : null}

Sitemap

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { buildSitemapEntries } from '@opinly/shared'
import { opinlyConfig } from '@opinly/next'
import { opinly } from '@/clients/opinly'

export const revalidate = false

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // One call feeds both the sitemap and generateStaticParams. buildSitemapEntries
  // shapes each typed route into an absolute URL — posts flat, categories/authors
  // prefixed per your config — so no manual URL building.
  const routes = await opinly.routes()
  return buildSitemapEntries(routes, opinlyConfig).map((e) => ({
    url: e.url,
    lastModified: new Date(e.lastModified),
  }))
}

RSS

// app/blog/rss.xml/route.ts
import { opinly } from '@/clients/opinly'

export const revalidate = false

export async function GET() {
  const items = await opinly.rss({ limit: 50 })
  // build your XML from items ({ slug, title, description, date, categories })
  // …
  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}

Keep content fresh

Because content is cached (force-cache / revalidate: false), use a webhook to invalidate exactly the paths that changed instead of guessing. See Webhooks.

Recap

withOpinlyConfigcreateOpinlyClient → route by URL (posts() / categories() / post() / author()) + a switch<OpinlyContent> for the body → generateOpinlyMetadata + OpinlyJsonLd for SEO → routes() / rss() for discovery.