<div align="center">

# @codepark-apps/seofaster-nextjs

### **Turn Keywords into Traffic-Driving Content**

[![npm version](https://img.shields.io/npm/v/@codepark-apps/seofaster-nextjs.svg?style=flat-square&color=0d9488)](https://www.npmjs.com/package/@codepark-apps/seofaster-nextjs)
[![License: MIT](https://img.shields.io/badge/License-MIT-0d9488.svg?style=flat-square)](https://opensource.org/licenses/MIT)

<br />

## 🚀 [seofaster.app](https://seofaster.app)

<br />

**SEO Faster** generates hundreds of SEO-optimized articles from your keywords using AI — in 19+ languages, with your brand voice.

This package lets you fetch and display that content on your Next.js website in minutes.

[Get Started](https://seofaster.app) · [Get API Key](https://seofaster.app/api-keys)

---

</div>

### Why SEO Faster?

- **Programmatic SEO at Scale** — Generate 100s of articles from a keyword list
- **19+ Languages** — Reach global audiences with native-quality content
- **Your Brand Voice** — AI learns your tone, style, and terminology
- **SEO-Optimized** — Built-in scoring, meta tags, FAQs, and schema markup
- **One-Click Publishing** — Push to WordPress, Webflow, Ghost, and more

## Installation

```bash
npm install @codepark-apps/seofaster-nextjs
```

## CLI Setup (Recommended)

The easiest way to set up webhooks is with our CLI:

```bash
npx @codepark-apps/seofaster-nextjs init
```

This command will:
- Detect your Next.js project
- Create `app/api/seofaster-webhook/route.ts` with cache invalidation
- Add `SEOFASTER_WEBHOOK_SECRET` to your `.env.local`

Then just add your webhook secret from the [SEO Faster dashboard](https://seofaster.app/settings/webhooks).

## Quick Start

### 1. Create a client

```typescript
// lib/seofaster.ts
import { createSEOFasterClient } from '@codepark-apps/seofaster-nextjs';

export const seoFaster = createSEOFasterClient({
  apiKey: process.env.SEOFASTER_SECRET_KEY!,
});
```

### 2. Fetch articles

```typescript
// app/blog/page.tsx
import { seoFaster } from '@/lib/seofaster';

export default async function BlogPage() {
  const { articles } = await seoFaster.getArticles({ limit: 10 });

  return (
    <div>
      {articles.map((article) => (
        <a key={article._id} href={`/blog/${article.slug}`}>
          <h2>{article.title}</h2>
          <p>{article.metaDescription}</p>
        </a>
      ))}
    </div>
  );
}
```

### 3. Display single article

```typescript
// app/blog/[slug]/page.tsx
import { seoFaster } from '@/lib/seofaster';
import { notFound } from 'next/navigation';

export default async function ArticlePage({
  params,
}: {
  params: { slug: string };
}) {
  const article = await seoFaster.getArticleBySlug(params.slug);

  if (!article) {
    notFound();
  }

  return (
    <article>
      <h1>{article.title}</h1>
      {/* Use content.html for full content with embedded images */}
      <div
        dangerouslySetInnerHTML={{
          __html: article.content.html || article.content.markdown,
        }}
      />
    </article>
  );
}
```

> **Tip:** Always prefer `content.html` over `content.markdown` — HTML includes all section images and rich formatting.

## API Reference

### `createSEOFasterClient(config)`

Creates a new SEO Faster client.

```typescript
const client = createSEOFasterClient({
  apiKey: 'cp_seof_sec_xxx', // Required: Your secret API key
  baseUrl: 'https://...', // Optional: Custom API URL
});
```

### `client.getArticles(options?)`

Fetches a list of published articles.

```typescript
const { articles, total, page, limit } = await client.getArticles({
  page: 1, // Page number (default: 1)
  limit: 10, // Articles per page (default: 10)
  locale: 'en', // Filter by locale (optional)
});
```

### `client.getArticleBySlug(slug, options?)`

Fetches a single article by its slug.

```typescript
const article = await client.getArticleBySlug('my-article-slug');
// Returns null if not found

// With locale
const article = await client.getArticleBySlug('my-article', { locale: 'es' });
```

### `client.getRelatedArticles(options?)`

Fetches related articles with a slim payload optimized for card display. Returns ~90% smaller payload than `getArticles()` by excluding HTML content.

```typescript
const relatedArticles = await client.getRelatedArticles({
  limit: 6,           // Max articles to return (default: 6, max: 20)
  locale: 'en',       // Filter by locale (optional)
  excludeSlug: 'current-article-slug', // Exclude current article (optional)
});
```

**Returns:** `SlimArticle[]` - Array of articles with metadata only (no content)

```typescript
interface SlimArticle {
  _id: string;
  slug: string;
  title: string;
  metaDescription?: string;
  featuredImage?: { url: string; alt?: string };
  publishedAt?: string;
  createdAt: string;
  locale?: string;
  category?: string;
  readingTime?: number;
  author?: { name: string; avatar?: string };
}
```

> **Performance Tip:** Use `getRelatedArticles()` for "Related Posts" sections. It fetches only the fields needed for article cards, reducing payload from ~200KB to ~5KB for 6 articles.

## Webhooks

SEO Faster can notify your application when articles are published, updated, or deleted. This enables real-time cache invalidation and other integrations.

### Quick Setup with CLI

```bash
npx @codepark-apps/seofaster-nextjs init
```

This creates everything you need automatically. See [CLI Setup](#cli-setup-recommended) above.

### Manual Setup

If you prefer manual setup, create the webhook handler yourself:

```typescript
// app/api/seofaster-webhook/route.ts
import { createWebhookHandler } from '@codepark-apps/seofaster-nextjs/webhook';
import { revalidateTag } from 'next/cache';

export const POST = createWebhookHandler({
  secret: process.env.SEOFASTER_WEBHOOK_SECRET!,

  onArticlePublished: async (article) => {
    console.log('New article published:', article.title);
    revalidateTag('articles');
  },

  onArticleUpdated: async (article) => {
    console.log('Article updated:', article.title);
    revalidateTag(`article-${article.slug}`);
    revalidateTag('articles');
  },

  onArticleDeleted: async (article) => {
    console.log('Article deleted:', article.slug);
    revalidateTag('articles');
  },
});
```

### Webhook Payload

Webhooks send **metadata only** (not full content). Use the API to fetch full content if needed:

```typescript
interface WebhookArticleData {
  id: string;        // MongoDB document ID
  slug: string;      // URL-friendly slug
  title: string;     // Article title
  locale: string;    // Content locale (e.g., 'en')
  category: string;  // Article category
  status: string;    // 'draft' | 'published'
  featuredImage: { url: string; alt: string } | null;
  publishedAt: string | null;
  updatedAt: string;
}
```

### Manual Signature Verification

If you need custom handling, verify signatures manually:

```typescript
import { verifyWebhookSignature } from '@codepark-apps/seofaster-nextjs/webhook';

const result = verifyWebhookSignature(
  rawBody,                                          // Raw request body string
  request.headers.get('X-Webhook-Signature')!,      // sha256=... signature
  process.env.SEOFASTER_WEBHOOK_SECRET!,            // Your webhook secret
  request.headers.get('X-Webhook-Timestamp')        // Optional: for replay protection
);

if (!result.valid) {
  return new Response(result.error, { status: 401 });
}
```

### Configure Webhook URL

1. Go to [seofaster.app](https://seofaster.app) → Settings → Webhooks
2. Enter your webhook URL (e.g., `https://yoursite.com/api/seofaster-webhook`)
3. Copy the generated secret to your `.env.local`:

```
SEOFASTER_WEBHOOK_SECRET=whsec_xxxxx
```

---

## ISR Caching Best Practices

When deploying SEO Faster content on Vercel, proper ISR (Incremental Static Regeneration) configuration is critical for performance and cost. Without it, every page visit triggers a fresh server render, consuming Fluid CPU.

### The Goal

Your pages should return these headers:
```
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT (or PRERENDER on first request)
```

**Not** this (which means no caching):
```
cache-control: private, no-cache, no-store
x-vercel-cache: MISS
```

### Common Pitfalls That Break ISR

#### 1. Missing `generateStaticParams` in `[locale]` Layout

If you're using `next-intl`, you **must** add `generateStaticParams` and `setRequestLocale` to your `[locale]/layout.tsx`:

```typescript
// app/[locale]/layout.tsx
import { setRequestLocale } from 'next-intl/server';

const locales = ['en', 'ar', 'de']; // Your supported locales

// Required for static generation
export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;

  // Required for static rendering with next-intl
  setRequestLocale(locale);

  // ... rest of layout
}
```

#### 2. Using `headers()` or `cookies()` in Root Layout

These dynamic APIs force **all pages** to be dynamically rendered:

```typescript
// ❌ BAD - Forces dynamic rendering for entire app
import { headers } from 'next/headers';

export default async function RootLayout({ children }) {
  const headersList = await headers(); // This breaks ISR!
  const locale = headersList.get('x-locale');
  // ...
}
```

```typescript
// ✅ GOOD - Let [locale] layout handle locale detection
export default function RootLayout({ children }) {
  return (
    <html lang="en" dir="ltr">
      <body>{children}</body>
    </html>
  );
}
```

#### 3. Using `revalidate = false` (Dangerous)

`revalidate = false` means "cache forever". If an error occurs during the initial render, that error response gets cached permanently — even across Vercel force-redeploys. Always use `revalidate = 3600` (1 hour) as a safety net:

```typescript
// app/[locale]/blog/page.tsx

// ✅ Recommended: Revalidate every hour as safety net
export const revalidate = 3600;

// ❌ Dangerous: Caches errors forever, survives redeploys
export const revalidate = false;
```

#### 4. Not Wrapping Fetch Functions with React `cache()`

Wrap your data fetching functions to deduplicate requests during renders:

```typescript
// lib/seofaster.ts
import { cache } from 'react';
import { createSEOFasterClient } from '@codepark-apps/seofaster-nextjs';

const client = createSEOFasterClient({
  apiKey: process.env.SEOFASTER_SECRET_KEY!,
});

// ✅ Wrapped with cache() for request deduplication
export const getArticles = cache(async (options = {}) => {
  return client.getArticles({
    ...options,
    next: { revalidate: 3600, tags: ['articles'] },
  });
});

export const getArticleBySlug = cache(async (slug: string, locale?: string) => {
  return client.getArticleBySlug(slug, {
    locale,
    next: { revalidate: 3600, tags: [`article-${slug}`] },
  });
});
```

#### 5. Middleware Interfering with Blog Routes

If your middleware sets headers or processes blog routes, it can break caching. Skip middleware for blog routes:

```typescript
// middleware.ts
import createMiddleware from 'next-intl/middleware';

const intlMiddleware = createMiddleware({
  locales: ['en', 'ar', 'de'],
  defaultLocale: 'en',
});

export default function middleware(request: Request) {
  const { pathname } = new URL(request.url);

  // Skip middleware for blog routes to enable ISR caching
  const blogPattern = /^\/(en|ar|de)\/blog(\/|$)/;
  if (blogPattern.test(pathname)) {
    return; // Let Next.js handle it directly
  }

  return intlMiddleware(request);
}
```

### Complete Blog Page Example

Here's a complete, ISR-optimized blog list page:

```typescript
// app/[locale]/blog/page.tsx
import { cache } from 'react';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { createSEOFasterClient } from '@codepark-apps/seofaster-nextjs';

// ISR: Revalidate every hour
export const revalidate = 3600;

const locales = ['en', 'ar', 'de'];

// Generate static params for all locales
export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

// Cached data fetcher
const getArticles = cache(async (locale: string) => {
  const client = createSEOFasterClient({
    apiKey: process.env.SEOFASTER_SECRET_KEY!,
  });

  return client.getArticles({
    locale,
    limit: 12,
    next: { revalidate: 3600, tags: [`articles-${locale}`] },
  });
});

export default async function BlogPage({ params }) {
  const { locale } = await params;

  // Required for static rendering with next-intl
  setRequestLocale(locale);

  const t = await getTranslations({ locale, namespace: 'blog' });
  const { articles, total } = await getArticles(locale);

  return (
    <main>
      <h1>{t('title')}</h1>
      {articles.map((article) => (
        <a key={article._id} href={`/${locale}/blog/${article.slug}`}>
          <h2>{article.title}</h2>
        </a>
      ))}
    </main>
  );
}
```

### Verifying ISR is Working

After deployment, check your page headers:

```bash
curl -I https://yoursite.com/en/blog
```

**Expected (ISR working):**
```
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
```

**Problem (no caching):**
```
cache-control: private, no-cache, no-store
x-vercel-cache: MISS
```

### Build Output Indicators

During `npm run build`, look for these symbols:

| Symbol | Meaning | ISR Status |
|--------|---------|------------|
| `○` | Static | ✅ Pre-rendered HTML |
| `●` | SSG | ✅ Static with `generateStaticParams` |
| `ƒ` | Dynamic | ❌ Server-rendered on every request |

Your blog pages should show `●` (SSG), not `ƒ` (Dynamic).

---

## Production Best Practices

### Use `createSEOFasterClient`, not `createCachedClient`

`createCachedClient` is **deprecated** as of v0.5.0. It uses `unstable_cache` which:
- Caches error responses permanently in Vercel's data cache
- Is deprecated in Next.js 16
- Cannot be cleared even with force-redeploys

```typescript
// ❌ Deprecated
import { createCachedClient } from '@codepark-apps/seofaster-nextjs/cache';
const client = createCachedClient({ apiKey: '...', enableUnstableCache: true });
const article = await client.getArticleBySlug('slug', 'en');

// ✅ Recommended
import { createSEOFasterClient } from '@codepark-apps/seofaster-nextjs';
const client = createSEOFasterClient({ apiKey: '...' });
const article = await client.getArticleBySlug('slug', { locale: 'en', next: { revalidate: 3600 } });
```

### Use `mode: 'hybrid'` for `generateStaticParams` (recommended)

Vercel **wipes the ISR cache on every deploy**. If `generateStaticParams` returns `[]` (no pages pre-rendered), the entire catalog must re-render on-demand after each deploy — every first request is a Fluid Compute invocation (Active CPU). Pre-rendering **everything** (`mode: 'full'`) avoids that but does not scale: thousands of pages mean a slow build and hundreds of simultaneous API calls.

`mode: 'hybrid'` is the balance — pre-render the newest `prerenderLimit` slugs per locale at build (warm immediately after a deploy, zero runtime CPU), and let the long tail generate on-demand:

```typescript
// lib/static-params.ts
import { createStaticParamsGenerator } from '@codepark-apps/seofaster-nextjs/static';

export const staticParams = createStaticParamsGenerator({
  apiKey: process.env.SEOFASTER_SECRET_KEY!,
  locales: ['en', 'de'],
  mode: 'hybrid',
  prerenderLimit: 200, // newest 200 per locale baked at build
});

// app/[locale]/blog/[slug]/page.tsx
export const revalidate = false;       // cache forever; webhooks invalidate per-tag
export const dynamicParams = true;     // tail generates on first request
export const generateStaticParams = () => staticParams.blogArticle();
// per-route budget: staticParams.blogArticle({ limit: 50 })
```

| Mode | Build cost | Post-deploy CPU | Best for |
| --- | --- | --- | --- |
| `full` | High (all slugs) | ~0 (all warm) | Small catalogs (<1k) |
| `hybrid` | Bounded (top-N) | Low (tail only) | Large catalogs ✅ |
| `empty` | None | High (full re-warm each deploy) | Prototypes only |

### Category-routed pSEO (e.g. `/vs/[slug]`, `/alternatives/[slug]`)

If different routes render different content categories, scope each route's params with `category`:

```typescript
// app/[locale]/alternatives/[slug]/page.tsx
export const generateStaticParams = () =>
  staticParams.blogArticle({ category: 'alternatives', limit: 100 });
```

### Sizing `prerenderLimit` + build tuning

Pre-rendering renders each page at build, so too many at once can exceed Next's per-page static-generation timeout (60s default) when each render fans out to the API (article + related). Two levers:

1. Keep `prerenderLimit` matched to your build throughput (start ~50–100/locale).
2. For higher counts, raise the timeout and cap build concurrency in `next.config`:

```typescript
const nextConfig = {
  staticPageGenerationTimeout: 180, // seconds per page (default 60)
  experimental: { staticGenerationMaxConcurrency: 4 }, // fewer parallel renders
};
```

Use a `cp_seof_sec_` secret key (not a public key) so build requests bypass the API rate limit.

### `revalidate = false` + working webhooks

Pair `revalidate = false` (cache forever) with the webhook handler so content edits invalidate per-tag instantly — no time-based regeneration needed. If you cannot run webhooks, use `revalidate = 3600` instead so stale content self-heals (note: `false` can cache an error page from a failed on-demand render until the next webhook/redeploy).

---

## Types

```typescript
interface ArticleContent {
  markdown: string; // Markdown content
  html?: string; // HTML content with embedded images (preferred)
}

interface Article {
  _id: string;
  title: string;
  slug: string;
  content: ArticleContent; // Use content.html for full rendering
  metaDescription?: string;
  featuredImage?: {
    url: string;
    alt?: string;
  };
  author?: {
    name: string;
    title?: string;
    photo?: { url: string; alt?: string };
  };
  faqs?: Array<{
    question: string;
    answer: string;
  }>;
  seoScore?: number;
  locale?: string;
  status: 'draft' | 'published';
  publishedAt?: string;
  createdAt: string;
  updatedAt: string;
}

// Slim article for related posts / card display (no HTML content)
interface SlimArticle {
  _id: string;
  slug: string;
  title: string;
  metaDescription?: string;
  featuredImage?: { url: string; alt?: string };
  publishedAt?: string;
  createdAt: string;
  locale?: string;
  category?: string;
  readingTime?: number;
  author?: { name: string; avatar?: string };
}
```

## Environment Variables

Add your secret API key to `.env.local`:

```
SEOFASTER_SECRET_KEY=cp_seof_sec_xxxxx
```

> **Note:** Use secret keys (`cp_seof_sec_*`) for Next.js since it runs on the server. Public keys (`cp_seof_pub_*`) are for static sites where keys are exposed in the browser.

Get your API key from [seofaster.app/api-keys](https://seofaster.app/api-keys).

## Custom Webhook Integration (Non-Next.js)

For custom implementations (Node.js, Python, PHP, etc.), here's the webhook format:

### HTTP Request

```
POST {your_webhook_url}
Content-Type: application/json
X-Webhook-Event: article.published
X-Webhook-Timestamp: 1703587200
X-Webhook-Signature: sha256=abc123def456...
```

### Payload Structure

```json
{
  "event": "article.published",
  "workspace": {
    "id": "workspace-mongo-id",
    "slug": "workspace-slug"
  },
  "data": {
    "id": "article-mongo-id",
    "slug": "article-url-slug",
    "title": "Article Title",
    "locale": "en",
    "category": "article",
    "status": "published",
    "featuredImage": {
      "url": "https://...",
      "alt": "Image description"
    },
    "publishedAt": "2024-12-26T12:00:00.000Z",
    "updatedAt": "2024-12-26T12:00:00.000Z"
  },
  "timestamp": 1703587200
}
```

### Signature Verification (Any Language)

```python
# Python example
import hmac
import hashlib

def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

```php
// PHP example
function verify_signature($payload, $signature, $secret) {
    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
    return hash_equals($expected, $signature);
}
```

```javascript
// Node.js example
const crypto = require('crypto');

function verifySignature(payload, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}
```

## License

MIT
