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)
Avery Stone
Published May 12, 2026
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)
Avery Stone
Published May 12, 2026

Route

Code
app/
  blogs/
    [slug]/
      page.tsx            # dynamic route for blog detail
lib/
  blogjs.ts     # blogjs client

Content rendering pipeline

  1. 1
    lib\blogjs.ts
    Code
    import { BlogClient } from "blogjs-space";
    
    const client = new BlogClient({
      apiKey: "xxxxx-xxxx-xxx-xxxx-xxxxxxxxxxxx",
    });
    
    export default client;
  2. 2
    app/blogs/[slug]/page.tsx

    dynamic route for blog details pages. Fetches blog data server-side based on slug param, then renders content blocks.

    Code
    import 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>
      );
    }