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:
| Framework | Approach |
|---|---|
| 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/typographyApply 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>| Modifier | What 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
| Field | Description | Usage |
|---|---|---|
metaTitle | SEO-optimized page title | <title> and og:title |
metaDescription | SEO meta description (under 160 characters) | <meta name="description"> and og:description |
featuredImageUrl | Hero/social sharing image URL | og:image and twitter:image |
featuredImageAlt | Alt text for the featured image | Image alt attribute |
publishedAt | ISO 8601 publish timestamp | article: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 Status | Description |
|---|---|
400 | Missing website_id when fetching by slug |
401 | The API key is missing, invalid, or revoked |
404 | No published blog matches the given slug or ID |
429 | You have exceeded the rate limit (100 req/min) |
500 | An 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.