Add a working blog to a Next.js 14+ App Router site in 10 minutes.
1. Install
npm install mentionwell-reader
2. Env vars
# .env.local
MENTIONWELL_API_URL=https://mentionwell.com
MENTIONWELL_SITE_SLUG=your-site-slug
MENTIONWELL_API_KEY=bgo_read_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3. Reader helper
// lib/blogoto.ts
import { getBlogPostViaApi, getBlogPostsViaApi, getAllBlogSlugsViaApi } from "mentionwell-reader/api";
const config = {
apiUrl: process.env.MENTIONWELL_API_URL!,
siteSlug: process.env.MENTIONWELL_SITE_SLUG!,
apiKey: process.env.MENTIONWELL_API_KEY!
};
export const listPosts = (page = 1, perPage = 12) => getBlogPostsViaApi(config, page, perPage);
export const getPost = (slug: string) => getBlogPostViaApi(config, slug);
export const allSlugs = () => getAllBlogSlugsViaApi(config);
4. Index page
// app/blog/page.tsx
import Link from "next/link";
import { listPosts } from "@/lib/blogoto";
export const revalidate = 300;
export default async function BlogIndex() {
const { posts } = await listPosts(1, 24);
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</main>
);
}
5. Detail page
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost, allSlugs } from "@/lib/blogoto";
import { prepareArticleHtml } from "mentionwell-reader/html-utils";
export const revalidate = 300;
export async function generateStaticParams() {
const slugs = await allSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) return {};
return {
title: post.metaTitle ?? post.title,
description: post.metaDescription ?? post.excerpt,
alternates: { canonical: post.canonicalUrl }
};
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) notFound();
const html = prepareArticleHtml(post.html);
return (
<article>
<h1>{post.title}</h1>
{post.publishedAt ? <time dateTime={post.publishedAt}>{post.publishedAt}</time> : null}
{/* className="wb-article-host" is REQUIRED — every platform style
(.wb-callout, .wb-youtube, .wb-table thead, .wb-tldr, .wb-faq) is
scoped to this class. Without it, callouts render as raw asides
and YouTube embeds as bare iframes. */}
<div className="wb-article-host" dangerouslySetInnerHTML={{ __html: html }} />
{post.jsonLd ? (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: post.jsonLd }} />
) : null}
</article>
);
}
6. Configure image hostnames (REQUIRED for next/image)
Mentionwell-generated articles embed images and featured-image thumbnails from a
handful of CDNs. next/image blocks any unconfigured host with Invalid src prop … hostname "X" is not configured under images in your next.config.js.
Add the following block to your next.config.ts (or next.config.js):
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// AI-generated featured images and infographics
{ protocol: "https", hostname: "**.fal.media" },
{ protocol: "https", hostname: "v3.fal.media" },
{ protocol: "https", hostname: "v3b.fal.media" },
// Public stock photography
{ protocol: "https", hostname: "images.unsplash.com" },
// Common CDNs your editorial team may upload to
{ protocol: "https", hostname: "**.cloudinary.com" },
{ protocol: "https", hostname: "cdn.shopify.com" },
// YouTube poster frames (used by the wb-youtube embed thumbnail)
{ protocol: "https", hostname: "i.ytimg.com" },
{ protocol: "https", hostname: "img.youtube.com" },
// Supabase storage for featured images you upload directly
{ protocol: "https", hostname: "*.supabase.co" },
{ protocol: "https", hostname: "*.supabase.in" }
]
}
};
export default nextConfig;
If you see Invalid src prop after publishing, copy the offending hostname
out of the error and add it to remotePatterns — Turbopack/Webpack picks the
change up on the next request, no rebuild needed.
7. Optional: instant publishing via webhook
// app/api/mentionwell-revalidate/route.ts
import { NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";
import crypto from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text();
const sig = req.headers.get("x-mentionwell-signature");
const expected = crypto.createHmac("sha256", process.env.MENTIONWELL_WEBHOOK_SECRET!).update(raw).digest("hex");
if (sig !== expected) return NextResponse.json({ ok: false }, { status: 401 });
const { post } = JSON.parse(raw);
revalidateTag("mentionwell:posts");
if (post?.slug) revalidatePath(`/blog/${post.slug}`);
return NextResponse.json({ ok: true });
}
8. Recommended detail-page layout
Match the structure used on production Mentionwell sites (drabinskyrealestate.com,
seth-muskoka.com) so the article reads consistently and SEO surfaces are all in
place. The shared @isaac/blog-reader stylesheet ships helper classes for
each piece — wrap them around your existing JSX.
// app/blog/[slug]/page.tsx (sketch)
import { prepareArticleHtml } from "mentionwell-reader/html-utils";
import { getPost } from "@/lib/blogoto";
import "@isaac/blog-reader/styles";
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) return null;
return (
<article>
{/* 1. Breadcrumbs at the very top — good for SEO + crawl orientation. */}
<nav className="wb-breadcrumbs" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/blog">Blog</a></li>
{post.category ? <li><a href={`/blog/category/${post.category.slug}`}>{post.category.title}</a></li> : null}
<li aria-current="page">{post.title}</li>
</ol>
</nav>
<h1>{post.title}</h1>
{/* 2. Three-column reader: sticky TOC | article | sticky CTA. */}
<div className="grid gap-10 lg:grid-cols-[220px_minmax(0,1fr)_240px]">
<aside className="wb-sticky-toc">
{/* Render post.toc here (left rail, does not scroll with page). */}
</aside>
<div
className="wb-article-host"
dangerouslySetInnerHTML={{ __html: prepareArticleHtml(post.html) }}
/>
<aside className="wb-sticky-cta">
<span className="wb-sticky-cta-eyebrow">Talk to us</span>
<h3>Need a hand?</h3>
<p>One-paragraph value prop tailored to this site.</p>
<a className="wb-sticky-cta-btn" href="/contact">Get in touch</a>
</aside>
</div>
{/* 3. Related posts grid below the article. */}
{post.relatedPosts.length > 0 && (
<section className="wb-related-grid">
<h2>Keep reading</h2>
<ul>
{post.relatedPosts.map((r) => (
<li key={r.id}>
<a href={`/blog/${r.slug}`}>
<span className="wb-related-meta">{post.category?.title ?? "Article"}</span>
<span className="wb-related-title">{r.title}</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
);
}
Why each piece matters:
- Breadcrumbs — surface the site's information architecture and feed
BreadcrumbListJSON-LD. Generate the JSON-LD server-side and inject with a<script type="application/ld+json">tag. - Sticky TOC (
.wb-sticky-toc) — non-scrolling left rail. Hidden below 1024px breakpoint; the inline.wb-tocinside the article serves mobile. - Sticky CTA (
.wb-sticky-cta) — high-converting right rail surface. Same dark/bronze palette across sites for visual consistency. - Related posts (
.wb-related-grid) — internal linking signal Google weighs heavily, plus dwell-time bump.
The shared CSS forces explicit colors on TL;DR, callouts, and CTAs to keep
contrast safe under aggressive host themes — do not override
color / background on .wb-tldr, .wb-cta, or .wb-callout-*
unless you also re-validate AA contrast.
9. RSS link
// app/layout.tsx <head>
<link
rel="alternate"
type="application/rss+xml"
title="Blog"
href={`${process.env.MENTIONWELL_API_URL}/api/sites/${process.env.MENTIONWELL_SITE_SLUG}/feed.xml`}
/>
That's the full integration. See Styling & theming for CSS and Webhooks for signature verification details.