Practical recipes that combine the SDK's components and hooks into complete pages.
Next.js App Router (server components)
The most efficient way to use Vlozi with Next.js is to fetch data on the server. This improves SEO and initial load performance.
// app/blog/[slug]/page.tsx
import { vlozi } from "@/lib/vlozi";
import { BlogContent } from "@vlozi/blog/react";
import { notFound } from "next/navigation";
export async function generateMetadata({ params }) {
const post = await vlozi.blog.get(params.slug).catch(() => null);
if (!post) return {};
return {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
};
}
export default async function BlogPostPage({ params }) {
const post = await vlozi.blog.get(params.slug).catch(() => null);
if (!post) {
notFound();
}
return (
<article className="container max-w-3xl py-10">
<h1 className="text-4xl font-bold">{post.title}</h1>
{/* Category & Tags */}
<div className="flex items-center gap-3 mt-4 text-sm text-muted-foreground">
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
{post.category && (
<span className="bg-primary/10 text-primary px-2 py-0.5 rounded-full text-xs">
{post.category.name}
</span>
)}
</div>
{post.tags && post.tags.length > 0 && (
<div className="flex gap-2 mt-2">
{post.tags.map((tag) => (
<span key={tag.slug} className="text-xs bg-muted px-2 py-0.5 rounded-full">
#{tag.name}
</span>
))}
</div>
)}
{/* Body — BlogContent applies the SDK's prose baseline, sanitizes,
and progressively hydrates mermaid / carousels / images.
Do NOT wrap in <article className="prose"> — see Quickstart Rule 2. */}
<BlogContent html={post.content} className="mt-8" />
</article>
);
}Static site generation (SSG)
If you use generateStaticParams, you can build your blog as a static site.
export async function generateStaticParams() {
const posts = await vlozi.blog.list({ limit: 100 });
return posts.data.map((post) => ({
slug: post.slug,
}));
}Category page
Build a page that shows all posts in a specific category:
// app/blog/category/[slug]/page.tsx
import { vlozi } from "@/lib/vlozi";
export default async function CategoryPage({ params }) {
const [posts, categories] = await Promise.all([
vlozi.blog.list({ category: params.slug, limit: 20 }),
vlozi.blog.categories.list(),
]);
const currentCategory = categories.data.find(
(c) => c.slug === params.slug
);
return (
<div className="container py-10">
<h1 className="text-3xl font-bold mb-2">
{currentCategory?.name || params.slug}
</h1>
<p className="text-muted-foreground mb-8">
{posts.meta.total} posts in this category
</p>
{posts.data.map((post) => (
<article key={post.slug} className="mb-6">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}Search page
Build a client-side search experience with debounced input:
"use client";
import { useState, useEffect, useRef } from "react";
import { usePosts } from "@vlozi/blog/react";
export default function SearchPage() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState<string>();
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
timer.current = setTimeout(() => {
setDebouncedQuery(query.trim() || undefined);
}, 300);
return () => clearTimeout(timer.current);
}, [query]);
const { data, loading } = usePosts({
search: debouncedQuery,
sort: "title",
order: "asc",
});
return (
<div className="container py-10">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search blog posts…"
className="w-full border rounded-lg px-4 py-2 mb-6"
/>
{loading && <p>Searching…</p>}
{data?.data.map((post) => (
<a key={post.slug} href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</a>
))}
{!loading && data?.data.length === 0 && (
<p>No posts found for "{query}"</p>
)}
</div>
);
}Upgrade body images to Next.js <Image>
Pass an imageComponent prop to <BlogContent> and every <img> tag in the post body is portal-mounted with your component. Featured images on cards/lists also accept the same prop. Your component receives src, alt, plus the original width/height when the editor recorded them — perfect for Next.js's required intrinsic sizing.
// components/SmartImage.tsx
"use client";
import NextImage from "next/image";
import type { VloziImageComponent } from "@vlozi/blog/react";
export const SmartImage: VloziImageComponent = ({ src, alt, width, height, className }) => (
<NextImage
src={src}
alt={alt ?? ""}
width={width ?? 1280}
height={height ?? 720}
sizes="(max-width: 768px) 100vw, 768px"
className={className}
/>
);
// app/blog/[slug]/page.tsx — pass it through to BlogContent
import { BlogContent } from "@vlozi/blog/react";
import { SmartImage } from "@/components/SmartImage";
<BlogContent html={post.content} imageComponent={SmartImage} />The same imageComponent prop works on <BlogList>, <BlogCard>, <BlogPost>, <BlogInfiniteList>, and <RelatedPosts>, so you can wire one component for the whole module.
Rewrite HTML before render (transformHtml escape hatch)
When you need to inject ads after the third paragraph, swap a deprecated shortcode, or strip a class your CMS leaves behind, use transformHtml. It runs after the SDK's sanitizer and before hydration, so anything you add still passes through the same defense-in-depth pipeline.
<BlogContent
html={post.content}
transformHtml={(html) => {
// Inject a CTA after the first <h2>
return html.replace(
/<h2/,
'<aside class="cta">Subscribe to our newsletter →</aside><h2'
);
}}
/>WARNING
The transform runs on every render. Memoize your transformer (useCallback) when the input HTML is stable to avoid re-parsing.
Polished loading with skeleton primitives
Replace ad-hoc <p>Loading…</p> with the SDK's shape-matched skeletons. They share the same surfaces and spacing as the real components, so the layout doesn't reflow on data arrival.
import {
BlogList,
BlogPost,
BlogListSkeleton,
BlogPostSkeleton,
} from "@vlozi/blog/react";
// List page
<BlogList columns={3} renderLoading={() => <BlogListSkeleton columns={3} count={6} />} />
// Detail page
<BlogPost
slug={params.slug}
renderLoading={() => <BlogPostSkeleton showFeaturedImage />}
renderNotFound={() => <p>Post not found</p>}
/>Blog with sidebar navigation
Combine BlogCategoryNav, BlogTagNav, and BlogList for a complete blog layout:
"use client";
import { useState } from "react";
import { BlogList, BlogCategoryNav, BlogTagNav } from "@vlozi/blog/react";
export default function BlogWithSidebar() {
const [category, setCategory] = useState<string | null>(null);
const [tag, setTag] = useState<string | null>(null);
return (
<div className="flex gap-8">
{/* Sidebar */}
<aside className="w-64 shrink-0">
<h3 className="font-bold mb-3">Categories</h3>
<BlogCategoryNav
variant="sidebar"
activeCategory={category ?? undefined}
onSelect={setCategory}
showCounts
/>
<h3 className="font-bold mt-8 mb-3">Tags</h3>
<BlogTagNav
variant="cloud"
activeTag={tag ?? undefined}
onSelect={setTag}
showCounts
/>
</aside>
{/* Main Content */}
<main className="flex-1">
<BlogList
category={category ?? undefined}
tag={tag ?? undefined}
columns={2}
showPagination
/>
</main>
</div>
);
}