WrytzeWrytze Docs
Guides

Blog Post Page

Build a single blog post page with content rendering and metadata.

This guide covers everything you need to build a single blog post page, regardless of your framework. You will learn how to fetch a blog by slug, render HTML content, style it with typography classes, build a table of contents, display metadata, and set up SEO tags.

If you are using a specific framework, check the dedicated guides for complete implementations:

Fetching a blog post

You can fetch a single blog by slug or by ID. Slugs are recommended for SEO-friendly URLs.

Fetch by slug

When fetching by slug, you must also provide your website_id since slugs are unique per website, not globally.

curl -H "X-API-Key: YOUR_API_KEY" \
  "https://app.wrytze.com/api/v1/blogs/slug/getting-started-with-react-19?website_id=ws_abc"

Fetch by ID

If you have the blog ID (for example, from a listing response), you can fetch directly by ID without a website ID.

curl -H "X-API-Key: YOUR_API_KEY" \
  "https://app.wrytze.com/api/v1/blogs/blog_abc123"

Prefer fetching by slug for public-facing blog pages. Slugs produce clean, SEO-friendly URLs like /blog/getting-started-with-react-19 instead of /blog/blog_abc123.

Rendering HTML content

The contentHtml field contains the full blog content as sanitized HTML, ready to render directly into your page.

Browser / framework rendering

In any framework, render the HTML into a container element:

<!-- Vanilla HTML / any template engine -->
<article class="prose prose-lg">
  <!-- Insert blog.contentHtml here -->
</article>

Framework-specific directives:

FrameworkApproach
React / Next.js<div dangerouslySetInnerHTML={{ __html: blog.contentHtml }} />
Vue / Nuxt<div v-html="blog.contentHtml" />
Astro<div set:html={blog.contentHtml} />
Svelte{@html blog.contentHtml}
Angular<div [innerHTML]="blog.contentHtml"></div>

The contentHtml is sanitized by Wrytze during generation and is safe to render. Never use raw HTML rendering directives with content from untrusted sources.

Styling content

The blog HTML contains standard semantic tags (<h1> through <h6>, <p>, <ul>, <ol>, <blockquote>, <pre>, <code>, <img>, <a>, <table>, etc.). The recommended approach is to use the @tailwindcss/typography plugin, which provides a set of prose classes that style all these elements beautifully with a single class.

Install the typography plugin

npm install @tailwindcss/typography

Apply prose classes

Wrap the blog content in an element with the prose class:

<article class="prose prose-lg max-w-none">
  <!-- blog.contentHtml goes here -->
</article>

Customizing prose styles

You can use Tailwind modifier classes to customize specific element styles within the prose container:

<article class="prose prose-lg max-w-none
  prose-headings:scroll-mt-20
  prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
  prose-img:rounded-lg prose-img:shadow-md
  prose-pre:bg-gray-900 prose-pre:text-gray-100
  prose-blockquote:border-blue-500">
  <!-- blog.contentHtml goes here -->
</article>
ModifierWhat it styles
prose-headings:All heading elements (h1-h6)
prose-a:Links (a tags)
prose-img:Images
prose-pre:Code blocks
prose-code:Inline code
prose-blockquote:Blockquotes
prose-strong:Bold text
prose-table:Tables

If you are not using Tailwind CSS, you can write your own styles targeting the semantic HTML elements within your blog content container. The contentHtml uses standard, well-structured HTML that responds well to any CSS approach.

Building a table of contents

You can build a table of contents by parsing headings from the contentHtml. Here are two approaches depending on your environment.

Regex approach (server-side)

Use a regular expression to extract heading tags and their text content. This works in any JavaScript runtime without a DOM.

interface TocEntry {
  id: string
  text: string
  level: number
}

function extractTableOfContents(html: string): TocEntry[] {
  const headingRegex = /<h([2-4])\s+id="([^"]*)"[^>]*>(.*?)<\/h[2-4]>/gi
  const entries: TocEntry[] = []
  let match: RegExpExecArray | null

  while ((match = headingRegex.exec(html)) !== null) {
    entries.push({
      level: parseInt(match[1], 10),
      id: match[2],
      text: match[3].replace(/<[^>]*>/g, ''), // Strip nested HTML tags
    })
  }

  return entries
}

// Usage
const toc = extractTableOfContents(blog.contentHtml)
// [
//   { id: "introduction", text: "Introduction", level: 2 },
//   { id: "getting-started", text: "Getting Started", level: 2 },
//   { id: "installation", text: "Installation", level: 3 },
//   ...
// ]

DOM approach (browser or SSR with DOM library)

If you have access to a DOM environment (browser or a library like linkedom or jsdom), you can use querySelectorAll:

interface TocEntry {
  id: string
  text: string
  level: number
}

function extractTableOfContents(html: string): TocEntry[] {
  const parser = new DOMParser()
  const doc = parser.parseFromString(html, 'text/html')
  const headings = doc.querySelectorAll('h2, h3, h4')

  return Array.from(headings).map((heading) => ({
    id: heading.id,
    text: heading.textContent ?? '',
    level: parseInt(heading.tagName[1], 10),
  }))
}

Rendering the table of contents

Once you have the TOC entries, render them as a nested list with anchor links:

function renderTableOfContents(entries: TocEntry[]): string {
  return `
    <nav aria-label="Table of contents">
      <ul>
        ${entries
          .map(
            (entry) => `
            <li style="margin-left: ${(entry.level - 2) * 16}px">
              <a href="#${entry.id}">${entry.text}</a>
            </li>
          `
          )
          .join('')}
      </ul>
    </nav>
  `
}

The Wrytze API automatically generates id attributes on heading elements in the contentHtml, making anchor links work out of the box. The heading scroll-mt-20 prose modifier shown in the styling section adds scroll margin so headings are not hidden behind fixed headers when navigating via anchor links.

Displaying metadata

Each blog object includes metadata fields that you can display on the blog post page.

Reading time and word count

// Format reading time
const readingTime = `${blog.readingTimeMinutes} min read`

// Format word count
const wordCount = `${blog.wordCount.toLocaleString()} words`

Published date

Format the publishedAt ISO 8601 timestamp into a human-readable date:

// Standard date formatting
const formattedDate = new Date(blog.publishedAt).toLocaleDateString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
})
// → "February 15, 2026"

// Relative date formatting (using Intl.RelativeTimeFormat)
function getRelativeTime(dateString: string): string {
  const date = new Date(dateString)
  const now = new Date()
  const diffInDays = Math.floor(
    (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
  )

  const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })

  if (diffInDays === 0) return rtf.format(0, 'day') // "today"
  if (diffInDays < 7) return rtf.format(-diffInDays, 'day') // "3 days ago"
  if (diffInDays < 30) return rtf.format(-Math.floor(diffInDays / 7), 'week')
  if (diffInDays < 365) return rtf.format(-Math.floor(diffInDays / 30), 'month')
  return rtf.format(-Math.floor(diffInDays / 365), 'year')
}

const relativeDate = getRelativeTime(blog.publishedAt)
// → "3 days ago"

Categories and tags

Display categories and tags as linked badges that navigate to filtered listing pages:

<!-- Categories -->
<div class="flex gap-2">
  <!-- For each category: -->
  <a
    href="/blog?category=technology"
    class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 hover:bg-blue-200"
  >
    Technology
  </a>
</div>

<!-- Tags -->
<div class="flex flex-wrap gap-2">
  <!-- For each tag: -->
  <a
    href="/blog?tag=react"
    class="rounded-full border px-3 py-1 text-sm text-gray-600 hover:bg-gray-50"
  >
    #React
  </a>
</div>

SEO metadata

Each blog includes metaTitle, metaDescription, and featuredImageUrl fields specifically designed for SEO. Use these to set the <head> meta tags on your blog post page.

Essential meta tags

<head>
  <title>{blog.metaTitle}</title>
  <meta name="description" content="{blog.metaDescription}" />

  <!-- Open Graph -->
  <meta property="og:title" content="{blog.metaTitle}" />
  <meta property="og:description" content="{blog.metaDescription}" />
  <meta property="og:type" content="article" />
  <meta property="og:image" content="{blog.featuredImageUrl}" />
  <meta property="article:published_time" content="{blog.publishedAt}" />

  <!-- Twitter Card -->
  <meta name="twitter:card" content="summary_large_image" />
  <meta name="twitter:title" content="{blog.metaTitle}" />
  <meta name="twitter:description" content="{blog.metaDescription}" />
  <meta name="twitter:image" content="{blog.featuredImageUrl}" />
</head>

Meta fields reference

FieldDescriptionUsage
metaTitleSEO-optimized page title<title> and og:title
metaDescriptionSEO meta description (under 160 characters)<meta name="description"> and og:description
featuredImageUrlHero/social sharing image URLog:image and twitter:image
featuredImageAltAlt text for the featured imageImage alt attribute
publishedAtISO 8601 publish timestamparticle:published_time

The metaTitle and metaDescription are AI-generated during blog creation and are specifically optimized for search engines. They may differ from the blog title and excerpt fields, which are written for human readers. Always use the meta variants for SEO tags.

Blog detail response reference

Here is the complete shape of a single blog response for reference:

{
  "data": {
    "id": "blog_abc123",
    "title": "Getting Started with React 19",
    "slug": "getting-started-with-react-19",
    "excerpt": "Learn how to build modern web applications with React 19...",
    "contentHtml": "<h2 id=\"introduction\">Introduction</h2><p>React 19 brings...</p>",
    "metaTitle": "Getting Started with React 19 | Acme Blog",
    "metaDescription": "A comprehensive guide to the latest React features...",
    "featuredImageUrl": "https://cdn.wrytze.com/images/react-19.jpg",
    "featuredImageAlt": "React 19 hero image",
    "wordCount": 1250,
    "readingTimeMinutes": 5,
    "publishedAt": "2026-02-15T10:00:00.000Z",
    "websiteId": "ws_xyz789",
    "categories": [
      { "name": "Technology", "slug": "technology" }
    ],
    "tags": [
      { "name": "React", "slug": "react" },
      { "name": "JavaScript", "slug": "javascript" }
    ]
  }
}

Error handling

When a blog is not found, the API returns a 404 status:

{
  "error": {
    "message": "Blog not found",
    "status": 404
  }
}

Common errors for the blog detail endpoint:

HTTP StatusDescription
400Missing website_id when fetching by slug
401The API key is missing, invalid, or revoked
404No published blog matches the given slug or ID
429You have exceeded the rate limit (100 req/min)
500An unexpected server error occurred

Always handle the 404 case gracefully. Show a user-friendly "post not found" page with a link back to the blog listing rather than a generic error page.

Next steps

On this page