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/nextnpm i @opinly/backend @opinly/react @opinly/nextyarn add @opinly/backend @opinly/react @opinly/nextConfigure 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)| Option | Notes |
|---|---|
blogPath | Must match the route segment your blog renders under (e.g. /blog). |
imagesPath | Local prefix rewritten to the CDN. Must not collide with your own assets. |
cdnNamespace | Exactly 21 characters. From Settings → Developers. |
siteUrl | Absolute 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
withOpinlyConfig → createOpinlyClient → route by URL (posts() / categories() / post()
/ author()) + a switch → <OpinlyContent> for the body → generateOpinlyMetadata +
OpinlyJsonLd for SEO → routes() / rss() for discovery.