@codepark-apps/seofaster-nextjs
v0.4.0
Published
SEO Faster API client for Next.js - fetch AI-generated SEO content and handle webhooks
Maintainers
Readme
@codepark-apps/seofaster-nextjs
Turn Keywords into Traffic-Driving Content
🚀 seofaster.app
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.
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
npm install @codepark-apps/seofaster-nextjsCLI Setup (Recommended)
The easiest way to set up webhooks is with our CLI:
npx @codepark-apps/seofaster-nextjs initThis command will:
- Detect your Next.js project
- Create
app/api/seofaster-webhook/route.tswith cache invalidation - Add
SEOFASTER_WEBHOOK_SECRETto your.env.local
Then just add your webhook secret from the SEO Faster dashboard.
Quick Start
1. Create a client
// lib/seofaster.ts
import { createSEOFasterClient } from '@codepark-apps/seofaster-nextjs';
export const seoFaster = createSEOFasterClient({
apiKey: process.env.SEOFASTER_SECRET_KEY!,
});2. Fetch articles
// 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
// 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.htmlovercontent.markdown— HTML includes all section images and rich formatting.
API Reference
createSEOFasterClient(config)
Creates a new SEO Faster client.
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.
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.
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.
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)
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
npx @codepark-apps/seofaster-nextjs initThis creates everything you need automatically. See CLI Setup above.
Manual Setup
If you prefer manual setup, create the webhook handler yourself:
// 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:
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:
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
- Go to seofaster.app → Settings → Webhooks
- Enter your webhook URL (e.g.,
https://yoursite.com/api/seofaster-webhook) - Copy the generated secret to your
.env.local:
SEOFASTER_WEBHOOK_SECRET=whsec_xxxxxISR 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: MISSCommon 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:
// 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:
// ❌ 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');
// ...
}// ✅ 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 Without Understanding It
revalidate = false means "cache forever, only invalidate via webhook". This can work, but revalidate = 3600 (1 hour) is safer:
// app/[locale]/blog/page.tsx
// ✅ Recommended: Revalidate every hour
export const revalidate = 3600;
// ⚠️ Use with caution: Requires webhook setup for updates
export const revalidate = false;4. Not Wrapping Fetch Functions with React cache()
Wrap your data fetching functions to deduplicate requests during renders:
// 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:
// 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:
// 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:
curl -I https://yoursite.com/en/blogExpected (ISR working):
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HITProblem (no caching):
cache-control: private, no-cache, no-store
x-vercel-cache: MISSBuild 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).
Types
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_xxxxxNote: 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.
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
{
"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 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 example
function verify_signature($payload, $signature, $secret) {
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}// 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
