# RankTraction Blog Sync Integration (Articles Only)

> **Purpose:** Connect an existing website to RankTraction for article publishing, updating, and deletion — without installing any UI packages or widgets.
> **Audience:** Developers integrating RankTraction blog management into an already-built website with its own tech stack.
> **Date:** May 6, 2026

---

## What This Guide Covers

This guide enables your existing website to:
- ✅ **Receive articles** published from the RankTraction dashboard
- ✅ **Auto-update articles** when re-published from RankTraction
- ✅ **Auto-delete articles** when removed from RankTraction
- ✅ **Prevent duplicate articles** via externalId-based upsert

This guide does **NOT** cover widgets, UI packages, ISR webhooks, or project scaffolding. It assumes your website is already built and deployed.

---

## Prerequisites

Your website needs:
1. A **Next.js App Router** project (any version 13+)
2. A **Postgres database** with an ORM (examples use Drizzle, but any ORM works)
3. A table to store blog articles with an `externalId` column

---

## Step 1: Add the `externalId` Column

Your articles table **must** have an `externalId` column. This is how RankTraction identifies articles for upsert and delete operations.

### Drizzle ORM Example

```typescript
// In your schema file
externalId: varchar('external_id', { length: 255 }).unique(),
```

### Raw SQL

```sql
ALTER TABLE your_articles_table
ADD COLUMN external_id VARCHAR(255) UNIQUE;

CREATE INDEX idx_external_id ON your_articles_table(external_id);
```

> **Why `externalId`?**
> When RankTraction publishes an article, it sends its internal UUID as `_id`. Your blog stores this in `externalId`. On re-publish, the blog looks up `externalId` to decide whether to INSERT (new) or UPDATE (existing). On delete, RankTraction sends `?external_id={uuid}` and the blog deletes the matching row.

---

## Step 2: Environment Variable

Add one environment variable to your `.env.local` and your hosting dashboard (Vercel/Netlify/etc.):

```env
# Secret shared between RankTraction and your blog
# You generate this value and enter it in:
#   RankTraction Dashboard → Your Website → Integrations → Sync API Secret
SYNC_API_SECRET=your-secret-here
```

---

## Step 3: Create the Sync API Route

Create the file `app/api/sync-articles/route.ts` (Next.js App Router).

This single file handles **three HTTP methods**:
- `OPTIONS` — CORS preflight
- `POST` — Create or update an article
- `DELETE` — Remove an article

### 3A. Authentication & CORS (shared by all methods)

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';              // Your database instance
import { articles } from '@/lib/schema';     // Your articles table
import { eq } from 'drizzle-orm';

// CORS headers — allow RankTraction to call this endpoint cross-origin
const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Secret',
};

// Authenticate incoming requests from RankTraction
function authenticate(request: NextRequest): boolean {
    const apiSecret = request.headers.get('X-API-Secret');
    const expected = process.env.SYNC_API_SECRET;
    if (!expected) {
        console.error('CRITICAL: SYNC_API_SECRET environment variable is not set');
        return false;
    }
    return !!(apiSecret && apiSecret === expected);
}

// CORS preflight handler
export async function OPTIONS() {
    return NextResponse.json({}, { headers: corsHeaders });
}
```

> **IMPORTANT:** Replace `@/lib/db` and `@/lib/schema` with your actual import paths. Replace `articles` with your actual table name (e.g., `blogPosts`, `posts`, `blog_posts`).

### 3B. POST — Create or Update an Article

RankTraction calls this when a user publishes or re-publishes an article from the dashboard.

**What RankTraction sends:**
```
POST /api/sync-articles
Headers:
  Content-Type: application/json
  X-API-Secret: {value of SYNC_API_SECRET}
Body: { _id, title, slug, content, html, article_html, metaTitle, metaDesc, ... }
```

```typescript
export async function POST(request: NextRequest) {
    if (!authenticate(request)) {
        return NextResponse.json(
            { success: false, error: 'Unauthorized' },
            { status: 401, headers: corsHeaders }
        );
    }

    try {
        const payload = await request.json();

        // ─── Validate required fields ───
        if (!payload._id || !payload.title || !payload.slug) {
            return NextResponse.json(
                { success: false, error: 'Missing required fields: _id, title, slug' },
                { status: 400, headers: corsHeaders }
            );
        }

        if (!payload.content && !payload.html && !payload.article_html) {
            return NextResponse.json(
                { success: false, error: 'Missing content (need content, html, or article_html)' },
                { status: 400, headers: corsHeaders }
            );
        }

        // ─── Map RankTraction payload to your schema ───
        // Adjust field names to match YOUR database columns
        const articleData = {
            externalId:      payload._id,
            title:           payload.title,
            slug:            payload.slug,
            content:         payload.article_html || payload.html || payload.content,
            excerpt:         payload.introduction || payload.metadata?.excerpt || null,
            category:        payload.category || null,
            heroImage:       payload.image || payload['thumbnailImage(Image)'] || null,
            author:          payload.authorName || payload.author || 'Editorial Team',
            authorBio:       payload.authorBio || null,
            authorData:      payload.authorData || null,
            metaTitle:       payload.metaTitle || null,
            metaDescription: payload.metaDesc || payload.meta_description || null,
            keywords:        payload.keywords || null,
            faqItems:        payload.faq || null,
            aioBox:          payload.aioBox || payload.aio_box || null,
            keyTakeaways:    payload.keyTakeaways ? JSON.stringify(payload.keyTakeaways) : null,
            schemaJson:      payload.schema || null,
            readingTime:     payload.metrics?.readingTimeMinutes || null,
            wordCount:       payload.metrics?.totalWords || null,
            images:          payload.images || null,
            videos:          payload.videos || null,
            publicationDate: payload['Created Date'] ? new Date(payload['Created Date']) : new Date(),
            updatedAt:       new Date(),
        };

        // ─── Upsert: find by externalId, then insert or update ───
        const existing = await db
            .select({ id: articles.id })
            .from(articles)
            .where(eq(articles.externalId, payload._id))
            .limit(1);

        let result;
        if (existing.length > 0) {
            // Update existing article
            result = await db.update(articles)
                .set(articleData)
                .where(eq(articles.externalId, payload._id))
                .returning({ id: articles.id, slug: articles.slug });
        } else {
            // Create new article
            result = await db.insert(articles)
                .values({ ...articleData, createdAt: new Date() })
                .returning({ id: articles.id, slug: articles.slug });
        }

        // ─── Optional: revalidate cached pages ───
        try {
            const { revalidatePath } = await import('next/cache');
            revalidatePath('/');
            revalidatePath('/blogs');
            revalidatePath(`/post/${payload.slug}`);
        } catch {}

        return NextResponse.json({
            success: true,
            action: existing.length > 0 ? 'updated' : 'created',
            article: result[0],
        }, { headers: corsHeaders });

    } catch (error: any) {
        console.error('[sync-articles] POST error:', error);
        return NextResponse.json(
            { success: false, error: error.message },
            { status: 500, headers: corsHeaders }
        );
    }
}
```

### 3C. DELETE — Remove an Article

RankTraction calls this when a user deletes an article (single or bulk) from the dashboard.

**What RankTraction sends:**
```
DELETE /api/sync-articles?external_id={article_uuid}
Headers:
  X-API-Secret: {value of SYNC_API_SECRET}
```

**Without this handler, deleted articles will remain published on your blog.**

```typescript
export async function DELETE(request: NextRequest) {
    if (!authenticate(request)) {
        return NextResponse.json(
            { success: false, error: 'Unauthorized' },
            { status: 401, headers: corsHeaders }
        );
    }

    try {
        const externalId = request.nextUrl.searchParams.get('external_id');

        if (!externalId) {
            return NextResponse.json(
                { success: false, error: 'Missing external_id query parameter' },
                { status: 400, headers: corsHeaders }
            );
        }

        // Find the article
        const existing = await db
            .select({ id: articles.id, slug: articles.slug })
            .from(articles)
            .where(eq(articles.externalId, externalId))
            .limit(1);

        if (existing.length === 0) {
            // Article doesn't exist — return 200 (idempotent, not an error)
            return NextResponse.json(
                { success: true, action: 'not_found', message: 'Article not found on this blog' },
                { status: 200, headers: corsHeaders }
            );
        }

        // Delete the article
        await db.delete(articles).where(eq(articles.externalId, externalId));
        console.log(`[sync-articles] Deleted article with external_id: ${externalId}`);

        // Optional: revalidate cached pages
        try {
            const { revalidatePath } = await import('next/cache');
            revalidatePath('/');
            revalidatePath('/blogs');
            revalidatePath(`/post/${existing[0].slug}`);
        } catch {}

        return NextResponse.json(
            { success: true, action: 'deleted', articleId: existing[0].id },
            { status: 200, headers: corsHeaders }
        );

    } catch (error: any) {
        console.error('[sync-articles] DELETE error:', error);
        return NextResponse.json(
            { success: false, error: error.message },
            { status: 500, headers: corsHeaders }
        );
    }
}
```

---

## Step 4: Configure RankTraction Dashboard

In the RankTraction dashboard for your website:

1. Go to **Your Website → Integrations**
2. Set **Sync Endpoint URL** to: `https://your-blog.com/api/sync-articles`
3. Set **Sync API Secret** to the same value as your `SYNC_API_SECRET` env var
4. Set **App Name** to match your site identifier
5. Toggle **Sync Active** to ON

---

## Step 5: Test the Integration

### Test POST (Create)

```bash
curl -X POST "https://your-blog.com/api/sync-articles" \
  -H "Content-Type: application/json" \
  -H "X-API-Secret: <value of your SYNC_API_SECRET>" \
  -d '{
    "_id": "test-uuid-12345",
    "title": "Test Article from RankTraction",
    "slug": "test-article-ranktraction",
    "appName": "your-app-name",
    "content": "<h1>Test</h1><p>This is a test article.</p>",
    "category": "General",
    "authorName": "Test Author"
  }'
```

**Expected response:**
```json
{ "success": true, "action": "created", "article": { "id": 1, "slug": "test-article-ranktraction" } }
```

### Test POST (Update — same `_id`)

Run the same curl again with a modified title. Expected response:
```json
{ "success": true, "action": "updated", "article": { "id": 1, "slug": "test-article-ranktraction" } }
```

### Test DELETE

```bash
curl -X DELETE "https://your-blog.com/api/sync-articles?external_id=test-uuid-12345" \
  -H "X-API-Secret: <value of your SYNC_API_SECRET>"
```

**Expected response:**
```json
{ "success": true, "action": "deleted", "articleId": 1 }
```

### Test DELETE (Idempotent — already deleted)

Run the same DELETE again. Expected response:
```json
{ "success": true, "action": "not_found", "message": "Article not found on this blog" }
```

---

## RankTraction Payload — Key Fields Reference

These are the most commonly used fields sent by RankTraction. Your POST handler should map whichever fields your schema supports.

| Field | Type | Required | Your Column |
|-------|------|----------|-------------|
| `_id` | string | ✅ | `externalId` |
| `title` | string | ✅ | `title` |
| `slug` | string | ✅ | `slug` |
| `article_html` / `html` / `content` | string | ✅ (one of) | `content` |
| `appName` | string | ✅ | — (validation only) |
| `category` | string | | `category` |
| `image` | string | | `heroImage` |
| `metaTitle` | string | | `metaTitle` |
| `metaDesc` | string | | `metaDescription` |
| `introduction` | string | | `excerpt` |
| `authorName` | string | | `author` |
| `authorData` | object | | `authorData` (JSONB) |
| `faq` | array | | `faqItems` (JSONB) |
| `aioBox` | object | | `aioBox` (JSONB) |
| `keyTakeaways` | array | | `keyTakeaways` |
| `schema` | object | | `schemaJson` (JSONB) |
| `keywords` | string | | `keywords` |
| `Created Date` | ISO string | | `publicationDate` |
| `Modified Date` | ISO string | | `updatedAt` |
| `metrics.totalWords` | number | | `wordCount` |
| `metrics.readingTimeMinutes` | number | | `readingTime` |
| `images` | array | | `images` (JSONB) |
| `videos` | array | | `videos` (JSONB) |

> You don't need to store every field. Map only the fields your schema supports. Unknown fields are safely ignored.

---

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| `401 Unauthorized` | `X-API-Secret` doesn't match `SYNC_API_SECRET` | Verify both values are identical |
| `405 Method Not Allowed` on delete | Missing `DELETE` export in route.ts | Add the DELETE handler from Step 3C |
| Duplicate slug errors | Missing `externalId` column | Add the column from Step 1 |
| Articles not appearing | `SYNC_API_SECRET` not set on server | Add the env var to your hosting dashboard |
| CORS errors | Missing `OPTIONS` handler | Add the OPTIONS handler from Step 3A |
| Old article still showing after delete | Missing cache revalidation | Add `revalidatePath()` calls in DELETE handler |

---

## Security Notes

1. **Rotate `SYNC_API_SECRET` periodically** — update both your env var and the RankTraction dashboard
2. **Always use HTTPS** — never expose the sync endpoint over HTTP
3. **No rate limiting is built in** — consider adding rate limiting for production
4. **Drizzle ORM uses parameterized queries** — SQL injection is prevented by default

---

*Last updated: May 6, 2026*
