Mentionwell

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 BreadcrumbList JSON-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-toc inside 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.