Next.js
Integrate Wrytze blogs into a Next.js App Router application.
This guide walks you through building a complete blog section in a Next.js App Router application. There are three approaches:
- Page-level components (recommended) —
BlogListPageandBlogPostPagerender entire pages with a single import. Fewest lines of code. - Composable components — Use
BlogList,BlogArticle, and other building blocks for a custom layout with@wrytze/reactprimitives. - SDK only — Full control using
@wrytze/sdkdirectly with your own components.
This guide covers all three, starting with the simplest approach.
Prerequisites
- A Next.js 15+ project using the App Router
- A Wrytze API key (see Quickstart)
- Your Wrytze website ID (found in Settings > Website in the dashboard)
Approach 1: Page-level components (recommended)
The fastest way to add a blog. BlogListPage and BlogPostPage render complete pages with filters, pagination, reading progress, table of contents, share buttons, related posts, and previous/next navigation — all in a single import.
Install packages
npm install @wrytze/sdk @wrytze/react @tailwindcss/typographyConfigure environment variables
WRYTZE_API_KEY=wrz_your_api_key_here
WRYTZE_WEBSITE_ID=ws_your_website_id_here
NEXT_PUBLIC_SITE_URL=https://yourdomain.comAllow Wrytze CDN images
Blog images are served from cdn.wrytze.com. Add it to your next.config.ts so next/image can optimize them:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.wrytze.com",
},
],
},
};
export default nextConfig;Without this configuration, next/image will refuse to load images from the
Wrytze CDN and you will see a broken image or a "Invalid src" error in the
browser console.
Create the API helper
import { WrytzeClient } from "@wrytze/sdk";
export const wrytze = new WrytzeClient({
apiKey: process.env.WRYTZE_API_KEY!,
websiteId: process.env.WRYTZE_WEBSITE_ID!,
});Build the blog listing page
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { BlogListPage } from '@wrytze/react/next'
import { generateBlogListMetadata } from '@wrytze/react/metadata'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300
export function generateMetadata(): Metadata {
return generateBlogListMetadata({
title: 'Blog',
description: 'Thoughts, guides, and updates from our team.',
})
}
interface Props {
searchParams: Promise<{ page?: string; category?: string; search?: string }>
}
export default async function BlogPage({ searchParams }: Props) {
const { page: pageParam, category, search } = await searchParams
const page = Math.max(1, parseInt(pageParam ?? '1', 10))
const [{ data: blogs, pagination }, { data: categories }] = await Promise.all([
wrytze.blogs.list({ page, limit: 12, category, search }),
wrytze.categories.list(),
])
return (
<Suspense>
<BlogListPage
blogs={blogs}
pagination={pagination}
categories={categories}
title="Blog"
description="Thoughts, guides, and updates from our team."
/>
</Suspense>
)
}BlogListPage renders the full page layout — title, category filters, search input, responsive card grid, empty state, and pagination. It automatically syncs filters and pagination to URL search params.
Build the blog post page
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { BlogPostPage as BlogPost } from '@wrytze/react/next'
import { generateBlogPostMetadata } from '@wrytze/react/metadata'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300
interface Props {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const result = await wrytze.blogs.getBySlug(slug).catch(() => null)
if (!result) return { title: 'Post not found' }
return generateBlogPostMetadata(result.data, {
siteName: 'My Site',
baseUrl: process.env.NEXT_PUBLIC_SITE_URL,
})
}
export async function generateStaticParams() {
const { data: blogs } = await wrytze.blogs.list({ limit: 100 })
return blogs.map((blog) => ({ slug: blog.slug }))
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params
const result = await wrytze.blogs.getBySlug(slug).catch(() => null)
if (!result) notFound()
return <BlogPost blog={result.data} />
}BlogPostPage renders the full article — reading progress bar, featured image, categories, title, excerpt, author card, share buttons, table of contents sidebar, HTML content in a prose container, tags, related posts, and previous/next navigation.
Configure Tailwind CSS
@import "tailwindcss";
@import "@wrytze/react/tailwind.css";
@plugin "@tailwindcss/typography";The @wrytze/react/tailwind.css import registers all component classes with Tailwind v4 automatically.
BlogListPage and BlogPostPage use next/image automatically and sync
state to URL params. If you need custom layouts, see Approach 2 for composable
building blocks or Approach 3 for full control with the SDK only.
Approach 2: Composable components
Use individual @wrytze/react building blocks (BlogList, BlogArticle, BlogCard, etc.) for a custom layout. This gives you control over the page structure while still leveraging pre-built components.
Build the blog listing page
import { Suspense } from 'react'
import { BlogList } from '@wrytze/react/next'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300
interface Props {
searchParams: Promise<{ page?: string; category?: string; search?: string }>
}
export default async function BlogPage({ searchParams }: Props) {
const { page: pageParam, category, search } = await searchParams
const page = Math.max(1, parseInt(pageParam ?? '1', 10))
const [{ data: blogs, pagination }, { data: categories }] = await Promise.all([
wrytze.blogs.list({ page, limit: 12, category, search }),
wrytze.categories.list(),
])
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<h1 className="mb-8 text-4xl font-bold tracking-tight">Blog</h1>
<Suspense>
<BlogList
blogs={blogs}
pagination={pagination}
categories={categories}
basePath="/blog"
/>
</Suspense>
</main>
)
}Build the blog detail page
import { notFound } from 'next/navigation'
import { BlogArticle } from '@wrytze/react/next'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const result = await wrytze.blogs.getBySlug(slug).catch(() => null)
if (!result) notFound()
return (
<main className="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
<BlogArticle blog={result.data} basePath="/blog" />
</main>
)
}Configure Tailwind CSS
@import "tailwindcss";
@import "@wrytze/react/tailwind.css";
@plugin "@tailwindcss/typography";See the React Components guide for all available building
blocks and their props. You can mix and match BlogCard, BlogSearch,
BlogFilter, ShareButtons, TableOfContents, and more.
Approach 3: SDK only (full control)
If you prefer writing your own components from scratch, use @wrytze/sdk directly.
Install the SDK
Install the Wrytze TypeScript SDK and the Tailwind CSS typography plugin for styling blog content:
npm install @wrytze/sdk @tailwindcss/typographyConfigure environment variables
Add your Wrytze credentials to .env.local at the root of your Next.js project:
WRYTZE_API_KEY=wrz_your_api_key_here
WRYTZE_WEBSITE_ID=ws_your_website_id_hereNever expose your API key in client-side code. The WRYTZE_API_KEY variable
intentionally omits the NEXT_PUBLIC_ prefix so it is only available on the
server.
Allow Wrytze CDN images
Blog images are served from cdn.wrytze.com. Add it to your next.config.ts so next/image can optimize them:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.wrytze.com",
},
],
},
};
export default nextConfig;Without this configuration, next/image will refuse to load images from the
Wrytze CDN and you will see a broken image or a "Invalid src" error in the
browser console.
Create the API helper
Create a shared Wrytze client instance that you can import from any Server Component or server-side function.
import { WrytzeClient } from "@wrytze/sdk";
export const wrytze = new WrytzeClient({
apiKey: process.env.WRYTZE_API_KEY!,
websiteId: process.env.WRYTZE_WEBSITE_ID!,
});Creating a single client instance at module scope is safe in Next.js. The SDK is stateless and does not hold open connections, so it works correctly with both serverless and long-running environments.
Build the blog listing page
Create the blog listing page at app/blog/page.tsx. This Server Component fetches blogs from Wrytze and renders them as a responsive grid of cards with pagination.
import Link from 'next/link'
import Image from 'next/image'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300 // ISR: revalidate every 5 minutes
interface BlogPageProps {
searchParams: Promise<{ page?: string }>
}
export default async function BlogListingPage({ searchParams }: BlogPageProps) {
const { page: pageParam } = await searchParams
const page = Math.max(1, parseInt(pageParam ?? '1', 10))
const limit = 12
const { data: blogs, pagination } = await wrytze.blogs.list({
page,
limit,
})
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<h1 className="mb-8 text-4xl font-bold tracking-tight">Blog</h1>
{/* Blog grid */}
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{blogs.map((blog) => (
<article key={blog.id} className="group">
<Link href={`/blog/${blog.slug}`} className="block">
{blog.featuredImageUrl && (
<div className="relative mb-4 aspect-[16/9] overflow-hidden rounded-lg">
<Image
src={blog.featuredImageUrl}
alt={blog.featuredImageAlt ?? blog.title}
fill
className="object-cover transition-transform group-hover:scale-105"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
)}
{/* Categories */}
{blog.categories.length > 0 && (
<div className="mb-2 flex gap-2">
{blog.categories.map((cat) => (
<span
key={cat.slug}
className="text-xs font-medium uppercase tracking-wide text-blue-600"
>
{cat.name}
</span>
))}
</div>
)}
<h2 className="mb-2 text-xl font-semibold group-hover:underline">
{blog.title}
</h2>
{blog.excerpt && (
<p className="mb-3 line-clamp-2 text-gray-600">
{blog.excerpt}
</p>
)}
<div className="flex items-center gap-3 text-sm text-gray-500">
<time dateTime={blog.publishedAt}>
{new Date(blog.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>·</span>
<span>{blog.readingTimeMinutes} min read</span>
</div>
</Link>
</article>
))}
</div>
{/* Pagination */}
{pagination.pages > 1 && (
<nav className="mt-12 flex items-center justify-center gap-4">
{page > 1 && (
<Link
href={`/blog?page=${page - 1}`}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Previous
</Link>
)}
<span className="text-sm text-gray-600">
Page {page} of {pagination.pages}
</span>
{page < pagination.pages && (
<Link
href={`/blog?page=${page + 1}`}
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Next
</Link>
)}
</nav>
)}
</main>
)
}The revalidate = 300 export enables Incremental Static Regeneration. Next.js
will cache the page for 5 minutes and rebuild it in the background on the next
request after the cache expires. Adjust this value based on how frequently you
publish new content.
Build the blog detail page
Create the individual blog post page at app/blog/[slug]/page.tsx. This page fetches a single blog by slug, renders the HTML content, and sets up full SEO metadata.
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import type { Metadata } from 'next'
import { wrytze } from '@/lib/wrytze'
export const revalidate = 300
interface BlogPostProps {
params: Promise<{ slug: string }>
}
// --- SEO Metadata ---
export async function generateMetadata({
params,
}: BlogPostProps): Promise<Metadata> {
const { slug } = await params
const result = await wrytze.blogs.getBySlug(slug).catch(() => null)
if (!result) return { title: 'Blog post not found' }
const blog = result.data
return {
title: blog.metaTitle,
description: blog.metaDescription,
openGraph: {
title: blog.metaTitle,
description: blog.metaDescription,
type: 'article',
publishedTime: blog.publishedAt,
images: blog.featuredImageUrl
? [{ url: blog.featuredImageUrl, alt: blog.featuredImageAlt ?? blog.title }]
: [],
},
twitter: {
card: 'summary_large_image',
title: blog.metaTitle,
description: blog.metaDescription,
images: blog.featuredImageUrl ? [blog.featuredImageUrl] : [],
},
}
}
// --- Static Generation ---
export async function generateStaticParams() {
const { data: blogs } = await wrytze.blogs.list({
limit: 100,
})
return blogs.map((blog) => ({
slug: blog.slug,
}))
}
// --- Page Component ---
export default async function BlogPostPage({ params }: BlogPostProps) {
const { slug } = await params
const result = await wrytze.blogs.getBySlug(slug).catch(() => null)
if (!result) notFound()
const blog = result.data
return (
<main className="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
{/* Header */}
<header className="mb-8">
{/* Categories */}
{blog.categories.length > 0 && (
<div className="mb-4 flex gap-2">
{blog.categories.map((cat) => (
<Link
key={cat.slug}
href={`/blog?category=${cat.slug}`}
className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 hover:bg-blue-200"
>
{cat.name}
</Link>
))}
</div>
)}
<h1 className="mb-4 text-4xl font-bold tracking-tight lg:text-5xl">
{blog.title}
</h1>
{/* Meta info */}
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<time dateTime={blog.publishedAt}>
{new Date(blog.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>·</span>
<span>{blog.readingTimeMinutes} min read</span>
<span>·</span>
<span>{blog.wordCount.toLocaleString()} words</span>
</div>
</header>
{/* Featured image */}
{blog.featuredImageUrl && (
<div className="relative mb-10 aspect-[16/9] overflow-hidden rounded-xl">
<Image
src={blog.featuredImageUrl}
alt={blog.featuredImageAlt ?? blog.title}
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
{/* Blog content */}
<article
className="prose prose-lg max-w-none prose-headings:scroll-mt-20 prose-a:text-blue-600 prose-img:rounded-lg"
dangerouslySetInnerHTML={{ __html: blog.contentHtml }}
/>
{/* Tags */}
{blog.tags.length > 0 && (
<footer className="mt-12 border-t pt-6">
<div className="flex flex-wrap gap-2">
{blog.tags.map((tag) => (
<Link
key={tag.slug}
href={`/blog?tag=${tag.slug}`}
className="rounded-full border px-3 py-1 text-sm text-gray-600 hover:bg-gray-50"
>
#{tag.name}
</Link>
))}
</div>
</footer>
)}
</main>
)
}Enable the typography plugin
Add @tailwindcss/typography to your Tailwind CSS configuration so the prose classes style blog HTML content correctly. In Tailwind CSS v4, plugins are added via @plugin in your CSS file:
@import "tailwindcss";
@plugin "@tailwindcss/typography";Add a loading skeleton
Create a loading state that displays while blog data is being fetched. Next.js automatically uses loading.tsx to show a fallback during server-side data fetching.
export default function BlogLoading() {
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="mb-8 h-10 w-48 animate-pulse rounded bg-gray-200" />
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-4">
<div className="aspect-[16/9] animate-pulse rounded-lg bg-gray-200" />
<div className="h-4 w-20 animate-pulse rounded bg-gray-200" />
<div className="h-6 w-full animate-pulse rounded bg-gray-200" />
<div className="h-4 w-3/4 animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
</main>
)
}export default function BlogPostLoading() {
return (
<main className="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
<div className="mb-4 h-6 w-24 animate-pulse rounded-full bg-gray-200" />
<div className="mb-4 h-12 w-full animate-pulse rounded bg-gray-200" />
<div className="mb-8 h-4 w-48 animate-pulse rounded bg-gray-200" />
<div className="aspect-[16/9] animate-pulse rounded-xl bg-gray-200" />
<div className="mt-10 space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-4 w-full animate-pulse rounded bg-gray-200" />
))}
</div>
</main>
)
}Full project structure
After completing all the steps above, your blog-related files should look like this:
your-nextjs-app/
├── app/
│ └── blog/
│ ├── page.tsx # Blog listing
│ ├── loading.tsx # Listing skeleton
│ └── [slug]/
│ ├── page.tsx # Blog post
│ └── loading.tsx # Post skeleton
├── lib/
│ └── wrytze.ts # SDK client instance
└── .env.local # API key & website ID