> ## Documentation Index
> Fetch the complete documentation index at: https://docs.rankbuddy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Set up blog pages with the SDK

> Official RankBuddy + Next.js App Router integration guide for AI coding agents and developers.

This is the **source of truth** for integrating a RankBuddy-powered blog into a Next.js App Router app. AI coding agents should follow every required section below and substitute project-specific values from the user's prompt (`BLOG_PATH`, `SITE_URL`, `PROJECT_NAME`).

<CardGroup cols={2}>
  <Card title="SDK on npm" icon="npm" href="https://www.npmjs.com/package/@rankbuddy/sdk">
    `@rankbuddy/sdk` — headless client, ISR helpers, React HTML renderer.
  </Card>

  <Card title="Article fields" icon="table" href="/sdk/article-fields">
    Full reference for `RankBuddyArticle` and `RankBuddyCluster`.
  </Card>
</CardGroup>

## What you are building

<Steps>
  <Step title="Routes">
    * `{BLOG_PATH}` — article index (listing + topic strip)
    * `{BLOG_PATH}/[slug]` — article detail
    * `{BLOG_PATH}/topic/[clusterSlug]` — topic hub (recommended)
    * `{BLOG_PATH}/layout.tsx` — passthrough layout for cache invalidation
  </Step>

  <Step title="Data">
    Server Components fetch published content via `@rankbuddy/sdk` ISR helpers (not raw `fetch` to RankBuddy).
  </Step>

  <Step title="SEO">
    Per-page `generateMetadata` using article `seo.*` fields, JSON-LD `BlogPosting`, and `robots` when `seo.noIndex`.
  </Step>

  <Step title="Styling">
    Clean Tailwind layout: readable prose for HTML body, card grid on index, violet accent `#7C3AED`, responsive and accessible.
  </Step>
</Steps>

Replace `{BLOG_PATH}` with the user's path (e.g. `/blog`). Replace `{SITE_URL}` with their production origin (e.g. `https://acme.com`).

***

## 1. Install dependencies

```bash theme={null}
npm install @rankbuddy/sdk@^0.3.5
```

Requires **Next.js ≥ 14.2** and **Node 18+**. React is optional unless you use `@rankbuddy/sdk/react`.

***

## 2. Environment variables

Add to `.env.local` and your hosting provider:

```bash theme={null}
RANKBUDDY_API_KEY=rb_...
```

<Warning>
  Server-only. Never prefix with `NEXT_PUBLIC_`. Never import the client in Client Components with a secret key.
</Warning>

***

## 3. SDK client (server)

Create `src/lib/rankbuddy.ts`:

```ts theme={null}
import { createRankBuddyClient } from "@rankbuddy/sdk";

function getRankBuddyApiKey() {
  const apiKey = process.env.RANKBUDDY_API_KEY;
  if (!apiKey) {
    throw new Error("RANKBUDDY_API_KEY is required to render RankBuddy blog pages.");
  }
  return apiKey;
}

const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
const blogPath = process.env.RANKBUDDY_BLOG_PATH ?? "/blog";

export const rankBuddy = createRankBuddyClient({
  apiKey: getRankBuddyApiKey(),
  siteUrl,
  blogPath,
});
```

Set `NEXT_PUBLIC_SITE_URL` and `RANKBUDDY_BLOG_PATH` to match the user's project, or hardcode values from the onboarding prompt.

***

## 4. Route map (App Router)

For `{BLOG_PATH} = /blog`:

```
src/app/blog/
  layout.tsx
  page.tsx
  [slug]/page.tsx
  topic/[clusterSlug]/page.tsx
```

Use a route group if needed (`src/app/(marketing)/blog/...`) — URLs stay `/blog/...`.

### Blog layout

`src/app/blog/layout.tsx`:

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

***

## 5. Use SDK ISR helpers (required)

On Next.js, **do not** call `rankBuddy.articles.getBySlug` directly in Server Components. Use cached helpers so `revalidateTag` works:

| Page                   | SDK helper                               | Cache tag                                                |
| ---------------------- | ---------------------------------------- | -------------------------------------------------------- |
| Index                  | `getArticlesList`, `getClustersList`     | `blogListingCacheTag()`                                  |
| Article                | `getArticleBySlug`                       | `blogArticleCacheTag(slug)`                              |
| Topic hub              | `getClusterBySlug`, `getClusterArticles` | `blogTopicListingCacheTag(clusterSlug)`                  |
| Related block          | `getRelatedArticles`                     | `blogRelatedCacheTag(slug)` + `blogRelatedAllCacheTag()` |
| `generateStaticParams` | `getArticlesList`, `getClusterSlugs`     | listing tag                                              |

```ts theme={null}
import {
  getArticleBySlug,
  getArticlesList,
  getClustersList,
  getClusterArticles,
  getClusterBySlug,
  getRelatedArticles,
  getClusterSlugs,
} from "@rankbuddy/sdk";
import { rankBuddy } from "@/lib/rankbuddy";

const post = await getArticleBySlug(rankBuddy, slug);
const posts = await getArticlesList(rankBuddy, { limit: 24 });
```

See [article fields](/sdk/article-fields) for every property to render.

***

## 6. Blog index (`{BLOG_PATH}/page.tsx`)

**Required behavior:**

* `export const revalidate = 3600` for first launch (see [Caching](#caching)); use `false` only with a revalidation strategy.
* Fetch `getArticlesList(rankBuddy, { limit: 24 })` and `getClustersList(rankBuddy)` in parallel.
* Render an empty state when there are no posts.
* Each card shows: `coverImage`, `title`, `excerpt || description`, `publishedAt`, `readingTime`, link to `{BLOG_PATH}/{slug}`.
* Topic strip links to `{BLOG_PATH}/topic/{cluster.slug}`.
* Static `generateMetadata` for the index (title, description, canonical, Open Graph).

**Minimal styling:** max-width container, responsive grid or stacked cards, subtle borders, hover states, sufficient color contrast.

***

## 7. Article page (`{BLOG_PATH}/[slug]/page.tsx`)

**Required behavior:**

* `generateStaticParams` — paginate `getArticlesList` (e.g. `limit: 100`) to prebuild slugs; wrap in try/catch so missing API key at build time does not fail CI.
* `generateMetadata` — map from article fields:

```ts theme={null}
export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getArticleBySlug(rankBuddy, slug);
  if (!post) return {};

  const title = post.seo?.title ?? post.title;
  const description = post.seo?.description ?? post.description;
  const image = post.seo?.ogImage ?? post.coverImage;
  const canonical = post.seo?.canonicalUrl ?? `${siteUrl}${blogPath}/${post.slug}`;

  return {
    title,
    description,
    keywords: post.seo?.keywords,
    alternates: { canonical },
    openGraph: {
      title,
      description,
      type: "article",
      url: canonical,
      images: image ? [{ url: image }] : undefined,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      images: image ? [image] : undefined,
    },
    robots: post.seo?.noIndex ? { index: false, follow: false } : undefined,
  };
}
```

Alternatively: `await rankBuddy.seo.generateMetadata(slug)` returns a Next-compatible metadata object.

**Page UI must include:**

| Element       | Source                                                    |
| ------------- | --------------------------------------------------------- |
| `<h1>`        | `post.title`                                              |
| Cover hero    | `post.coverImage`                                         |
| Byline        | `publishedAt`, `readingTime`                              |
| Topic link    | `post.primaryCluster` → topic page                        |
| Tags          | `post.tags` or `post.seo.keywords`                        |
| Body          | `post.content.html` (see below)                           |
| Related posts | cluster articles or `getRelatedArticles(rankBuddy, slug)` |
| Back link     | to `{BLOG_PATH}`                                          |

**Article body — choose one:**

```tsx theme={null}
// Option A (recommended): sanitized React helper
import { RenderContent } from "@rankbuddy/sdk/react";
<RenderContent content={post.content} />

// Option B: Tailwind Typography prose + trusted server HTML
<div
  className="prose prose-slate max-w-none dark:prose-invert"
  dangerouslySetInnerHTML={{ __html: post.content.html }}
/>
```

Hide duplicate `<h1>` in HTML if the page already renders `post.title` (e.g. `[&>h1:first-child]:hidden`).

**JSON-LD (recommended):** `BlogPosting` + `BreadcrumbList` scripts with `headline`, `description`, `image`, `datePublished`, `dateModified`, `url`.

Call `notFound()` when `getArticleBySlug` returns `null`.

***

## 8. Topic hub (`{BLOG_PATH}/topic/[clusterSlug]/page.tsx`)

**Required behavior:**

* `generateStaticParams` from `getClusterSlugs(rankBuddy)`.
* `getClusterBySlug(rankBuddy, clusterSlug)` — `notFound()` if null.
* `getClusterArticles(rankBuddy, clusterSlug, { limit: 24 })` for the grid.
* Metadata from `cluster.name`, `cluster.notes`, `cluster.pillarKeyword`, `cluster.keywords`.
* Same card component as the index.

***

## 9. Caching

### First launch (recommended)

```ts theme={null}
export const revalidate = 3600; // refresh at most once per hour
```

Published articles appear on your site within the revalidation window without extra infrastructure.

### Production (on-demand)

```ts theme={null}
export const revalidate = false;
```

Pair with `revalidateTag` + `revalidatePath` after publishes. Tags from `@rankbuddy/sdk`:

* `blogArticleCacheTag(slug)`
* `blogListingCacheTag()`
* `blogRelatedAllCacheTag()`
* `blogRelatedCacheTag(slug)`
* `blogTopicListingCacheTag(clusterSlug)`

```ts theme={null}
import { revalidatePath, revalidateTag } from "next/cache";
import {
  blogArticleCacheTag,
  blogListingCacheTag,
  blogRelatedAllCacheTag,
} from "@rankbuddy/sdk";

revalidateTag(blogArticleCacheTag(slug));
revalidateTag(blogListingCacheTag());
revalidateTag(blogRelatedAllCacheTag());
revalidatePath("/blog", "layout");
revalidatePath("/sitemap.xml");
```

***

## 10. Base styling spec

Agents should ship **production-ready** pages without a design system dependency:

* Font: system sans or project default
* Accent: use the **primary brand color** from the onboarding prompt (`Primary brand color: #RRGGBB`). If none was detected from the site, RankBuddy defaults to `#0F172A` (Tailwind `slate-900`)
* Index: card layout with 16:9 cover thumbnails, title, 2-line excerpt
* Article: max-width \~`48rem` prose column, optional sidebar
* Dark mode: support if the host app uses `dark:` classes
* Images: `alt` from `title`, lazy loading, `object-cover` on cards
* Accessibility: semantic `<article>`, `<time dateTime>`, focus states on links

***

## 11. Agent checklist

Before finishing, verify:

<Check>All three routes exist under the user's `{BLOG_PATH}`.</Check>
<Check>`RANKBUDDY_API_KEY` is read only on the server.</Check>
<Check>SDK ISR helpers used instead of raw client list/get calls.</Check>
<Check>Every `RankBuddyArticle` SEO field in [article fields](/sdk/article-fields) is mapped in `generateMetadata`.</Check>
<Check>`seo.noIndex` sets `robots` correctly.</Check>
<Check>`generateStaticParams` tolerates missing API key at build time.</Check>
<Check>Empty index state explains that posts come from RankBuddy after publish.</Check>
<Check>User can follow [Publish your first article](/guides/publish-first-article) after deploy.</Check>

***

## Next step

Deploy, then publish from [RankBuddy](https://rankbuddy.io) — see [Publish your first article](/guides/publish-first-article).
