Next.js Setup
Blog detail
The detail page renders a single blog with its full content block tree. Blocks are typed and rendered by a renderBlock() switch.
Theme preview
Detail page layout
A quick visual pass at how the copied template reads in both light and dark interfaces.
Light theme
Designing with headless content
blogjs
Product6 min read
Designing with headless content
A preview of the detail template with hero media, article typography, content blocks, and a compact author footer.
Content blocks stay structured while the page keeps your brand system.
renderBlock(block)
Dark theme
Designing with headless content
blogjs
Product6 min read
Designing with headless content
A preview of the detail template with hero media, article typography, content blocks, and a compact author footer.
Content blocks stay structured while the page keeps your brand system.
renderBlock(block)
Route
Code
app/
blogs/
[slug]/
page.tsx # dynamic route for blog detail
lib/
blogjs.ts # blogjs clientContent rendering pipeline
- 1lib\blogjs.tsCode
import { BlogClient } from "blogjs-space"; const client = new BlogClient({ apiKey: "xxxxx-xxxx-xxx-xxxx-xxxxxxxxxxxx", }); export default client; - 2app/blogs/[slug]/page.tsx
dynamicroute for blog details pages. Fetches blog data server-side based onslugparam, then renders content blocks.Codeimport client from "@/lib/blogjs"; import Image from "next/image"; const imageBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:3000"; type ContentBlock = | { type: "paragraph"; text: string } | { type: "heading"; text: string; level?: number } | { type: "list"; items: string[]; ordered?: boolean } | { type: "quote"; text: string } | { type: "image"; src: string; alt?: string; caption?: string } | { type: "code"; code: string; language?: string }; interface Blog { id: string; title: string; slug: string; description?: string; image?: { url?: string | null } | null; readTime?: number; publishedAt?: string; author: { name: string }; categories?: { name: string }; tags?: { name: string }[]; content?: ContentBlock[] | string; } function renderBlock(block: ContentBlock, index: number) { switch (block.type) { case "paragraph": return ( <p key={index} className="mb-5 text-base leading-relaxed" dangerouslySetInnerHTML={{ __html: block.text }} /> ); case "heading": const Tag = block.level === 3 ? "h3" : "h2"; return ( <Tag key={index} className="text-2xl font-semibold mt-8 mb-4" dangerouslySetInnerHTML={{ __html: block.text }} /> ); case "list": const ListTag = block.ordered ? "ol" : "ul"; return ( <ListTag key={index} className="mb-5 pl-6 list-disc"> {block.items.map((item, i) => ( <li key={i} dangerouslySetInnerHTML={{ __html: item }} /> ))} </ListTag> ); case "quote": return ( <blockquote key={index} className="border-l-4 border-primary pl-4 italic my-6 text-muted-foreground" dangerouslySetInnerHTML={{ __html: block.text }} /> ); case "image": return ( <figure key={index} className="my-8"> <div className="relative w-full h-[260px] md:h-[420px]"> <Image src={block.src || ""} alt={block.alt || ""} fill className="object-cover rounded-xl" /> </div> {block.caption && ( <figcaption className="text-center text-sm mt-2"> {block.caption} </figcaption> )} </figure> ); case "code": return ( <pre key={index} className="bg-surface-code text-foreground p-4 rounded-lg border border-border overflow-x-auto my-6" > <code>{block.code}</code> </pre> ); default: return null; } } function formatDate(dateString: string) { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); } function normalizeContent(content?: ContentBlock[] | string): ContentBlock[] { if (!content) return []; if (Array.isArray(content)) return content; try { const parsed = JSON.parse(content) as unknown; return Array.isArray(parsed) ? (parsed as ContentBlock[]) : []; } catch { return []; } } export default async function BlogPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const blog: Blog | null = await client.getBlogBySlug(slug); if (!blog) { return <p className="p-10">Blog not found</p>; } const content = normalizeContent(blog.content); return ( <div className="max-w-4xl mx-auto px-4 py-14"> {/* Category + Meta */} <div className="flex items-center gap-4 mb-6 text-sm text-muted-foreground"> {blog.categories?.name && ( <span className="text-xs font-medium px-3 py-1 bg-muted/40 border border-border rounded-full text-primary"> {blog.categories.name} </span> )} {blog.readTime && <span>{blog.readTime} min read</span>} </div> {/* Title */} <h1 className="text-4xl font-bold mb-6">{blog.title}</h1> {/* Hero Image */} {blog.image?.url && ( <div className="relative w-full h-[200px] md:h-[420px] mb-10"> <Image src={blog.image.url || ""} alt={blog.title} fill className="object-cover rounded-xl" /> </div> )} {/* Description */} {blog.description && ( <p className="text-lg text-muted-foreground mb-10"> {blog.description} </p> )} {/* Content */} <article> {content.map((block, i) => renderBlock(block, i))} </article> {/* Footer */} <footer className="flex flex-col md:flex-row justify-between items-start gap-4 md:items-center mt-12 pt-6 border-t border-border"> <div> <p className="text-xs text-muted-foreground mb-3 font-medium uppercase tracking-wide"> Tags </p> <div className="flex flex-wrap gap-2"> {blog.tags?.map((tag, index) => ( <span key={index} className="text-xs px-3 py-1 bg-muted/40 border border-border rounded-full text-muted-foreground" > {tag.name} </span> ))} </div> </div> <div className="flex items-center gap-3 pt-5"> <div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-xs font-semibold flex-shrink-0 border"> {(blog.author?.name || "A") .split(" ") .map((w) => w[0]) .join("") .slice(0, 2)} </div> <div> <p className="text-sm font-medium"> {blog.author?.name || "No author"} </p> <p className="text-xs text-muted-foreground"> {blog.publishedAt ? formatDate(blog.publishedAt) : "Not published"} </p> </div> </div> </footer> </div> ); }