# Next.js (App Router)

> Add /blog and /blog/[slug] to a Next.js App Router site with ISR and tag-based revalidation.

Add a working blog to a Next.js 14+ App Router site in 10 minutes.

## 1. Install

```bash
npm install mentionwell-reader
```

## 2. Env vars

```bash
# .env.local
MENTIONWELL_API_URL=https://mentionwell.com
MENTIONWELL_SITE_SLUG=your-site-slug
MENTIONWELL_API_KEY=bgo_read_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

## 3. Reader helper

```ts
// 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

```tsx
// 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

```tsx
// 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`):

```ts
// 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

```ts
// 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.

```tsx
// 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

```tsx
// 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](/docs/styling) for CSS and [Webhooks](/docs/webhooks) for signature verification details.


---

Canonical URL: https://mentionwell.com/docs/frameworks/nextjs
Live HTML version: https://mentionwell.com/docs/frameworks/nextjs
Section: Quickstarts by stack
Site index for AI ingestion: https://mentionwell.com/llms.txt
Full reference: https://mentionwell.com/llms-full.txt
