WrytzeWrytze Docs
Templates

Blog Post

A full blog article layout with table of contents, metadata, and share buttons.

A complete, copy-paste ready blog post page with a featured image, article content rendered via @tailwindcss/typography, a sticky table of contents with scroll-spy, share buttons, and skeleton loading states. Built with Tailwind CSS for Next.js App Router.

This template uses dangerouslySetInnerHTML to render blog content HTML. The Wrytze API returns sanitized HTML, but you should always ensure your content source is trusted.

Types

Extended type definitions for a single blog post response.

types.ts
export interface BlogPost {
  id: string
  title: string
  slug: string
  excerpt: string | null
  contentHtml: string
  metaTitle: string | null
  metaDescription: string | null
  featuredImageUrl: string | null
  featuredImageAlt: string | null
  wordCount: number
  readingTimeMinutes: number
  publishedAt: string
  websiteId: string
  categories: { name: string; slug: string }[]
  tags: { name: string; slug: string }[]
}

export interface BlogPostResponse {
  data: BlogPost
}

Heading Utilities

A utility function to extract headings from the HTML content string for building the table of contents.

lib/headings.ts
export interface Heading {
  level: 2 | 3
  id: string
  text: string
}

export function extractHeadings(html: string): Heading[] {
  const matches = html.matchAll(/<(h[23])\s[^>]*id="([^"]*)"[^>]*>(.*?)<\/\1>/gi)
  return Array.from(matches).map((m) => ({
    level: m[1].toLowerCase() === 'h2' ? 2 : 3,
    id: m[2],
    text: m[3].replace(/<[^>]+>/g, ''),
  }))
}

BlogArticle

The main article component that renders the blog post content with a featured image, metadata, category and tag badges, and the HTML body inside a prose container.

components/blog-article.tsx
import Image from 'next/image'
import type { BlogPost } from '@/types'

function formatDate(dateString: string): string {
  return new Date(dateString).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  })
}

export function BlogArticle({ post }: { post: BlogPost }) {
  return (
    <article>
      {/* Featured Image */}
      {post.featuredImageUrl && (
        <div className="relative mb-8 aspect-[2/1] overflow-hidden rounded-xl">
          <Image
            src={post.featuredImageUrl}
            alt={post.featuredImageAlt || post.title}
            fill
            priority
            className="object-cover"
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 66vw, 800px"
          />
        </div>
      )}

      {/* Categories */}
      {post.categories.length > 0 && (
        <div className="mb-4 flex flex-wrap gap-2">
          {post.categories.map((category) => (
            <a
              key={category.slug}
              href={`/blog?category=${category.slug}`}
              className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-950 dark:text-blue-300 dark:hover:bg-blue-900"
            >
              {category.name}
            </a>
          ))}
        </div>
      )}

      {/* Title */}
      <h1 className="mb-4 text-3xl font-bold leading-tight tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl lg:text-5xl">
        {post.title}
      </h1>

      {/* Metadata Row */}
      <div className="mb-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-gray-200 pb-6 text-sm text-gray-500 dark:border-gray-800 dark:text-gray-400">
        <time dateTime={post.publishedAt} className="flex items-center gap-1.5">
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
          </svg>
          {formatDate(post.publishedAt)}
        </time>

        <span className="flex items-center gap-1.5">
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
          </svg>
          {post.readingTimeMinutes} min read
        </span>

        <span className="flex items-center gap-1.5">
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
          </svg>
          {post.wordCount.toLocaleString()} words
        </span>
      </div>

      {/* Content */}
      <div
        className="prose prose-lg max-w-none prose-headings:scroll-mt-20 prose-headings:font-semibold prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline prose-img:rounded-lg dark:prose-invert dark:prose-a:text-blue-400"
        dangerouslySetInnerHTML={{ __html: post.contentHtml }}
      />

      {/* Tags */}
      {post.tags.length > 0 && (
        <div className="mt-10 border-t border-gray-200 pt-6 dark:border-gray-800">
          <div className="flex flex-wrap items-center gap-2">
            <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
              Tags:
            </span>
            {post.tags.map((tag) => (
              <a
                key={tag.slug}
                href={`/blog?tag=${tag.slug}`}
                className="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-sm text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
              >
                #{tag.name}
              </a>
            ))}
          </div>
        </div>
      )}
    </article>
  )
}

TableOfContents

A sticky sidebar component that displays linked headings extracted from the blog content. Includes scroll-spy to highlight the currently visible section.

components/table-of-contents.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import type { Heading } from '@/lib/headings'

export function TableOfContents({ headings }: { headings: Heading[] }) {
  const [activeId, setActiveId] = useState<string>('')
  const observerRef = useRef<IntersectionObserver | null>(null)

  useEffect(() => {
    const elements = headings
      .map((h) => document.getElementById(h.id))
      .filter(Boolean) as HTMLElement[]

    if (elements.length === 0) return

    observerRef.current = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((entry) => entry.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)

        if (visible.length > 0) {
          setActiveId(visible[0].target.id)
        }
      },
      {
        rootMargin: '-80px 0px -60% 0px',
        threshold: 0,
      }
    )

    elements.forEach((el) => observerRef.current?.observe(el))

    return () => {
      observerRef.current?.disconnect()
    }
  }, [headings])

  if (headings.length === 0) return null

  return (
    <nav aria-label="Table of contents" className="sticky top-24">
      <h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
        On this page
      </h2>
      <ul className="space-y-1 border-l-2 border-gray-200 dark:border-gray-800">
        {headings.map((heading) => (
          <li key={heading.id}>
            <a
              href={`#${heading.id}`}
              onClick={(e) => {
                e.preventDefault()
                document.getElementById(heading.id)?.scrollIntoView({ behavior: 'smooth' })
                setActiveId(heading.id)
              }}
              className={`block border-l-2 -ml-[2px] py-1 text-sm transition-colors ${
                heading.level === 3 ? 'pl-6' : 'pl-4'
              } ${
                activeId === heading.id
                  ? 'border-blue-600 font-medium text-blue-600 dark:border-blue-400 dark:text-blue-400'
                  : 'border-transparent text-gray-500 hover:border-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300'
              }`}
            >
              {heading.text}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  )
}

ShareButtons

A row of share buttons for copying the page link and sharing to Twitter/X and LinkedIn.

components/share-buttons.tsx
'use client'

import { useState } from 'react'

export function ShareButtons({ url, title }: { url: string; title: string }) {
  const [copied, setCopied] = useState(false)

  async function copyLink() {
    try {
      await navigator.clipboard.writeText(url)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      // Fallback for older browsers
      const textArea = document.createElement('textarea')
      textArea.value = url
      document.body.appendChild(textArea)
      textArea.select()
      document.execCommand('copy')
      document.body.removeChild(textArea)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    }
  }

  const encodedUrl = encodeURIComponent(url)
  const encodedTitle = encodeURIComponent(title)

  return (
    <div className="flex items-center gap-2">
      <span className="text-sm font-medium text-gray-700 dark:text-gray-300">Share:</span>

      {/* Copy Link */}
      <button
        onClick={copyLink}
        className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100"
        title={copied ? 'Copied!' : 'Copy link'}
      >
        {copied ? (
          <svg className="h-4 w-4 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
          </svg>
        ) : (
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
            <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m9.86-2.939a4.5 4.5 0 0 0-1.242-7.244l-4.5-4.5a4.5 4.5 0 0 0-6.364 6.364L4.343 8.52" />
          </svg>
        )}
      </button>

      {/* Twitter / X */}
      <a
        href={`https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`}
        target="_blank"
        rel="noopener noreferrer"
        className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100"
        title="Share on X"
      >
        <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
          <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
        </svg>
      </a>

      {/* LinkedIn */}
      <a
        href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`}
        target="_blank"
        rel="noopener noreferrer"
        className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100"
        title="Share on LinkedIn"
      >
        <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
          <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
        </svg>
      </a>
    </div>
  )
}

BlogPostSkeleton

A skeleton loading state that mirrors the blog post layout for a smooth loading experience.

components/blog-post-skeleton.tsx
export function BlogPostSkeleton() {
  return (
    <div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
      <div className="lg:grid lg:grid-cols-[1fr_250px] lg:gap-12">
        {/* Main Content Skeleton */}
        <div>
          {/* Featured Image */}
          <div className="mb-8 aspect-[2/1] animate-pulse rounded-xl bg-gray-200 dark:bg-gray-800" />

          {/* Category Badges */}
          <div className="mb-4 flex gap-2">
            <div className="h-6 w-20 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800" />
            <div className="h-6 w-24 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800" />
          </div>

          {/* Title */}
          <div className="mb-2 h-10 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
          <div className="mb-4 h-10 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />

          {/* Metadata Row */}
          <div className="mb-8 flex gap-4 border-b border-gray-200 pb-6 dark:border-gray-800">
            <div className="h-4 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
            <div className="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
            <div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
          </div>

          {/* Content Lines */}
          <div className="space-y-3">
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-5/6 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-3/4 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="mt-6 h-6 w-1/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-5/6 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="mt-6 h-6 w-1/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            <div className="h-4 w-3/4 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
          </div>
        </div>

        {/* TOC Sidebar Skeleton */}
        <div className="hidden lg:block">
          <div className="sticky top-24 space-y-2">
            <div className="mb-3 h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
            <div className="space-y-2 border-l-2 border-gray-200 pl-4 dark:border-gray-800">
              <div className="h-3 w-32 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
              <div className="ml-2 h-3 w-28 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
              <div className="ml-2 h-3 w-24 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
              <div className="h-3 w-36 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
              <div className="h-3 w-30 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
              <div className="ml-2 h-3 w-26 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

Full Page

The complete blog post page that wires all components together. This is a Next.js App Router server component that fetches a single blog by slug and renders a two-column layout with the article and a sticky table of contents sidebar.

Replace the API key and website ID with your own. Get your API key from Settings → API in the Wrytze dashboard. Set WRYTZE_API_KEY and WRYTZE_WEBSITE_ID in your environment variables. Never expose your API key in client-side code.

app/blog/[slug]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import type { BlogPostResponse } from '@/types'
import { extractHeadings } from '@/lib/headings'
import { BlogArticle } from '@/components/blog-article'
import { BlogPostSkeleton } from '@/components/blog-post-skeleton'
import { ShareButtons } from '@/components/share-buttons'
import { TableOfContents } from '@/components/table-of-contents'

const API_BASE = process.env.WRYTZE_API_URL ?? 'https://app.wrytze.com'
const API_KEY = process.env.WRYTZE_API_KEY!
const WEBSITE_ID = process.env.WRYTZE_WEBSITE_ID!
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'

async function fetchBlog(slug: string): Promise<BlogPostResponse | null> {
  const response = await fetch(
    `${API_BASE}/api/v1/blogs/slug/${slug}?website_id=${WEBSITE_ID}`,
    {
      headers: { 'X-API-Key': API_KEY },
      next: { revalidate: 60 },
    }
  )

  if (response.status === 404) return null
  if (!response.ok) throw new Error(`Failed to fetch blog: ${response.status}`)

  return response.json()
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const result = await fetchBlog(slug)

  if (!result) {
    return { title: 'Post not found' }
  }

  const { data: post } = result

  return {
    title: post.metaTitle || post.title,
    description: post.metaDescription || post.excerpt,
    openGraph: {
      title: post.metaTitle || post.title,
      description: post.metaDescription || post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      images: post.featuredImageUrl ? [{ url: post.featuredImageUrl, alt: post.featuredImageAlt }] : [],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.metaTitle || post.title,
      description: post.metaDescription || post.excerpt,
      images: post.featuredImageUrl ? [post.featuredImageUrl] : [],
    },
  }
}

async function BlogPostContent({ slug }: { slug: string }) {
  const result = await fetchBlog(slug)

  if (!result) notFound()

  const { data: post } = result
  const headings = extractHeadings(post.contentHtml)
  const postUrl = `${SITE_URL}/blog/${post.slug}`

  return (
    <div className="lg:grid lg:grid-cols-[1fr_250px] lg:gap-12">
      {/* Main Content */}
      <div>
        <BlogArticle post={post} />

        <div className="mt-8 flex items-center justify-between border-t border-gray-200 pt-6 dark:border-gray-800">
          <ShareButtons url={postUrl} title={post.title} />
        </div>
      </div>

      {/* Table of Contents Sidebar */}
      {headings.length > 0 && (
        <aside className="hidden lg:block">
          <TableOfContents headings={headings} />
        </aside>
      )}
    </div>
  )
}

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params

  return (
    <main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
      <Suspense fallback={<BlogPostSkeleton />}>
        <BlogPostContent slug={slug} />
      </Suspense>
    </main>
  )
}

Not Found Page

An optional 404 page for when a blog post slug does not match any published content.

app/blog/[slug]/not-found.tsx
import Link from 'next/link'

export default function BlogNotFound() {
  return (
    <main className="mx-auto flex max-w-7xl flex-col items-center justify-center px-4 py-24 text-center sm:px-6 lg:px-8">
      <h1 className="mb-2 text-4xl font-bold text-gray-900 dark:text-gray-100">
        Post not found
      </h1>
      <p className="mb-8 text-lg text-gray-600 dark:text-gray-400">
        The blog post you are looking for does not exist or has been removed.
      </p>
      <Link
        href="/blog"
        className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
      >
        <svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
          <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
        </svg>
        Back to all posts
      </Link>
    </main>
  )
}

JSON-LD Structured Data

Add structured data to your blog post page for better SEO. Include this in your page component or a layout.

components/blog-json-ld.tsx
import type { BlogPost } from '@/types'

export function BlogJsonLd({
  post,
  siteUrl,
}: {
  post: BlogPost
  siteUrl: string
}) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.metaDescription || post.excerpt,
    image: post.featuredImageUrl || undefined,
    datePublished: post.publishedAt,
    wordCount: post.wordCount,
    url: `${siteUrl}/blog/${post.slug}`,
    keywords: post.tags.map((t) => t.name).join(', '),
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  )
}

To use it, render <BlogJsonLd post={post} siteUrl={SITE_URL} /> inside your page component alongside <BlogArticle />.

On this page