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 using the Wrytze TypeScript SDK. By the end you will have a paginated blog listing page, individual blog post pages with full SEO, and static generation for optimal performance.
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)
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.
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!,
})
export const WEBSITE_ID = 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, WEBSITE_ID } 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({
websiteId: WEBSITE_ID,
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, WEBSITE_ID } 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 { data: blog } = await wrytze.blogs.getBySlug(slug, {
websiteId: WEBSITE_ID,
})
if (!blog) {
return { title: 'Blog post not found' }
}
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({
websiteId: WEBSITE_ID,
limit: 100,
})
return blogs.map((blog) => ({
slug: blog.slug,
}))
}
// --- Page Component ---
export default async function BlogPostPage({ params }: BlogPostProps) {
const { slug } = await params
const { data: blog } = await wrytze.blogs.getBySlug(slug, {
websiteId: WEBSITE_ID,
})
if (!blog) {
notFound()
}
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