SEO for SaaS
Start
AI snippets

Next.js AI snippet

Adds /blog to a Next.js App Router project: pages, SEO metadata, sitemap, webhook refresh.

Next.js (App Router)/blog and /blog/[slug] with full SEO metadata, a sitemap, and a refresh on the next visit when an article is published or changes.nextjs@4 · api v1 · tested: Next.js 16 on a fresh create-next-app, built and run against the example article
Or open inClaude CodeCursorLovableReplit
# Add the SEO for SaaS blog to this Next.js app

Articles are written and published by SEO for SaaS and read from its content API. This app renders them. Follow the steps in order; the code below is tested, so use it as written and adapt only import paths.

## Done when
- `/blog` lists the published articles, newest first.
- `/blog/[slug]` renders the title as the h1 and every block through the installed components.
- Each article page has title, description, canonical, Open Graph and one JSON-LD script.
- The sitemap lists every article with its `lastModified`, from `getSitemap()`.
- A signed webhook from SEO for SaaS refreshes the blog; an unsigned one gets 401.

## Before changing anything
1. Read `package.json`. This targets Next.js 15 or 16 with the App Router (`app/` or `src/app/`). With only `pages/`, stop and tell the user.
2. Note whether the project uses `src/` (paths below assume it; otherwise drop `src/`), its import alias for the project root (paths below use `@/`), and its package manager (use its lockfile's tool instead of npm).
3. Ask the user to set Dashboard → your site → **Settings** → **Site and URLs** → **Articles live under** to `/blog` (canonical URLs and links between articles are built on it; unset means the site root). If they use another path, mount the pages there instead of `/blog`.
4. If `app/blog` or `app/sitemap.ts` already exists, STOP and ask the user whether to replace it, merge into it, or use another path.
5. If `node_modules/next/dist/docs/` exists, it is the documentation for the installed Next.js version: check it when unsure about an API.

## 1. Environment
Add to `.env.local` (it must be gitignored) and ask the user for the two secrets. Never write a real key into any other file.
```
SEOFORSAAS_PROJECT_ID=YOUR_PROJECT_ID
SEOFORSAAS_CONTENT_KEY=      # Delivery → Content API keys → Create key
SEOFORSAAS_WEBHOOK_SECRET=   # Delivery → Webhook → Signing secret → Reveal
SITE_URL=http://localhost:3000   # this app's dev URL; the production origin in production
```

## 2. Install
If `components.json` exists:
```
npx shadcn@latest add https://seoforsaas.dev/r/client.json https://seoforsaas.dev/r/webhook.json https://seoforsaas.dev/r/article.json
npm install server-only
```
Otherwise:
```
for f in lib/seoforsaas/types.ts lib/seoforsaas/client.ts lib/seoforsaas/webhook.ts \
  components/seoforsaas/article.tsx components/seoforsaas/blocks.tsx components/seoforsaas/rich-text.tsx \
  components/seoforsaas/video.tsx components/seoforsaas/json-ld.tsx components/seoforsaas/article.css; do
  curl -fsSL --create-dirs -o "src/$f" "https://seoforsaas.dev/r/files/$f"
done
npm install standardwebhooks server-only
```
Leave the installed files as they are: customization goes through `<Article components={…}>` and CSS variables, so reinstalling stays a clean overwrite.

## 3. Create these files

`src/lib/site.ts`:
```ts
/**
 * The site's public origin. Canonical URLs, Open Graph URLs and the sitemap
 * resolve against it, so a production build without it fails here, by name,
 * instead of publishing pages that point at localhost.
 */
function siteUrl(): URL {
  const value = process.env.SITE_URL;
  if (value) return new URL(value);
  if (process.env.NODE_ENV === "production") {
    throw new Error("SITE_URL is not set: canonical URLs and the sitemap need the site's origin");
  }
  return new URL("http://localhost:3000");
}

export const SITE_URL = siteUrl();
```

`src/lib/blog.ts`:
```ts
import "server-only";
import { cache } from "react";
import { createClient } from "@/lib/seoforsaas/client";

/** Every read carries this tag, so one webhook call refreshes the whole blog. */
export const BLOG_TAG = "seoforsaas";

const seoforsaas = createClient({
  projectId: process.env.SEOFORSAAS_PROJECT_ID,
  contentKey: process.env.SEOFORSAAS_CONTENT_KEY,
  // Requests that carry an Authorization header are cached only when asked.
  requestInit: { cache: "force-cache", next: { tags: [BLOG_TAG] } },
});

// cache(): generateMetadata and the page share one read per request.
export const listArticles = cache(() => seoforsaas.listAllArticles());
export const getArticle = cache((slug: string) => seoforsaas.getArticle(slug));
export const getSitemap = cache(() => seoforsaas.getSitemap());
```

`src/app/blog/layout.tsx`:
```tsx
// The article styles, once for every blog page.
import "@/components/seoforsaas/article.css";

export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return children;
}
```

`src/app/blog/page.tsx`:
```tsx
import type { Metadata } from "next";
import Link from "next/link";
import { listArticles } from "@/lib/blog";

export const metadata: Metadata = { title: "Blog" };

export default async function BlogIndex() {
  const articles = await listArticles();
  return (
    <main>
      <h1>Blog</h1>
      {articles.length === 0 && <p>No articles yet.</p>}
      <ul>
        {articles.map((article) => (
          <li key={article.id}>
            <h2>
              <Link href={`/blog/${article.slug}`}>{article.title}</Link>
            </h2>
            <p>{article.seo.metaDescription}</p>
            <time dateTime={article.createdAt}>{new Date(article.createdAt).toLocaleDateString("en")}</time>
          </li>
        ))}
      </ul>
    </main>
  );
}
```

`src/app/blog/[slug]/page.tsx`:
```tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Article } from "@/components/seoforsaas/article";
import { JsonLd } from "@/components/seoforsaas/json-ld";
import { safeHref } from "@/components/seoforsaas/rich-text";
import { getArticle, listArticles } from "@/lib/blog";

type Props = { params: Promise<{ slug: string }> };

// Prerender what exists at build time; an article published later renders on
// its first visit and is cached from then on.
export async function generateStaticParams() {
  const articles = await listArticles();
  return articles.map(({ slug }) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const article = await getArticle((await params).slug);
  if (!article) return {};
  const { seo } = article;
  return {
    title: seo.metaTitle,
    description: seo.metaDescription,
    keywords: seo.keywords,
    alternates: { canonical: seo.canonicalPath },
    openGraph: {
      type: "article",
      title: seo.openGraph.title,
      description: seo.openGraph.description,
      images: seo.openGraph.image ? [seo.openGraph.image] : undefined,
      publishedTime: article.createdAt,
      modifiedTime: article.updatedAt,
      authors: seo.author ? [seo.author.name] : undefined,
    },
  };
}

export default async function ArticlePage({ params }: Props) {
  const article = await getArticle((await params).slug);
  if (!article) notFound();
  return (
    <article>
      <h1>{article.title}</h1>
      {article.thumbnailUrl && (
        // eslint-disable-next-line @next/next/no-img-element -- hosted and sized by the API's CDN
        <img
          src={article.thumbnailUrl}
          alt=""
          width={1536}
          height={1024}
          style={{ maxWidth: "100%", height: "auto" }}
        />
      )}
      <Article blocks={article.blocks} />
      {article.related.length > 0 && (
        <nav aria-label="Related articles">
          <h2>Related</h2>
          <ul>
            {article.related.map((link) => {
              const href = safeHref(link.href);
              return href ? (
                <li key={href}>
                  <Link href={href}>{link.title}</Link>
                </li>
              ) : null;
            })}
          </ul>
        </nav>
      )}
      <JsonLd data={article.seo.jsonLd} />
    </article>
  );
}
```

`src/app/sitemap.ts` (merge the article entries if a sitemap exists):
```ts
import type { MetadataRoute } from "next";
import { getSitemap } from "@/lib/blog";
import { SITE_URL } from "@/lib/site";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Articles arrive ready: the canonical URL and the date a reader last saw
  // the page change. Add your own pages next to them.
  return [{ url: SITE_URL.toString() }, ...(await getSitemap())];
}
```

`src/app/api/seoforsaas/route.ts`:
```ts
import { revalidateTag } from "next/cache";
import { BLOG_TAG } from "@/lib/blog";
import { seoforsaasWebhook } from "@/lib/seoforsaas/webhook";

// Verifies the signature (401 if it fails). On Next.js 15, call revalidateTag(BLOG_TAG).
export const POST = seoforsaasWebhook(() => revalidateTag(BLOG_TAG, { expire: 0 }));
```

## 4. Root layout
In the root layout, import `SITE_URL` from `@/lib/site` and add `metadataBase: SITE_URL` to its `metadata`, keeping everything else.

## Rules
- The content key and signing secret stay in environment variables, read on the server only. No client component imports `src/lib/blog.ts`.
- `SITE_URL` must be set in production; `src/lib/site.ts` fails the build without it on purpose.
- Render article bodies only through `<Article>`; to restyle a block pass `components={{ <type>: YourComponent }}`. Never `dangerouslySetInnerHTML` for article content.
- Use `article.seo.jsonLd` as it is; never write article JSON-LD by hand.
- Do not change the cache options in `src/lib/blog.ts`: without `force-cache` Next.js does not cache authorized fetches, and the webhook would have nothing to refresh.

## Check your work
1. `npx next typegen` (Next.js 16), then `npx tsc --noEmit` and the project's lint pass.
2. `npx next build` succeeds and lists `/blog/[slug]` entries as prerendered.
3. With the dev server running: `/blog` returns 200, an article page returns 200, an unknown slug returns 404.
4. An article page's HTML has one `<title>`, a meta description, a canonical link, `og:` tags and exactly one `<script type="application/ld+json"` tag. Count the tag, not the string: the string also appears in Next.js's page data.
5. A POST without signature headers to `/api/seoforsaas` on the dev server returns 401.
6. `/sitemap.xml` lists every article URL with a `<lastmod>`; its origin is the site's own. If it isn't, the user's `SITE_URL` and the website URL in the dashboard (Settings → Site and URLs) disagree: tell them.
7. Without the user's key you can still check rendering: `https://api.seoforsaas.dev/public/v1/fixtures/article` needs no key and has every block type. Render its `blocks` through `<Article>` in a scratch page, confirm headings, lists, table, callouts, FAQ, video poster and CTA appear, then delete the scratch page.

## Report back
- Files created or changed.
- What the user must do: fill the two secrets locally and in the host, deploy, paste `https://<their domain>/api/seoforsaas` into Delivery → Webhook, select Send test event.
- Anything you skipped, and why.

The code in this snippet comes from the Next.js guide, which you can also follow by hand.