📖 Overview
AutoRanker uses a Push Model — when you click Generate in the dashboard, AutoRanker generates SEO articles and automatically pushes them to your site via a secure API endpoint.
Your site exposes a POST /api/v1/publish endpoint protected by an AUTORANKER_API_KEY. AutoRanker sends articles to this endpoint using your key stored as PUBLISH_API_KEY in its settings.
What happens when you click Generate:
- AutoRanker generates SEO-optimized articles using AI
- AI generates a unique featured image for each article (using Nano Banana Pro)
- Articles include SEO metadata (title, meta description) and 2-3 internal links to your existing pages
- Each article + image (base64) is pushed to
https://yoursite.com/api/v1/publish - Your site saves the article and image to the PostgreSQL database
- The article becomes available at
/blog/[slug]with proper<title>and<meta description>tags - Image served at
/api/images/[slug] - Links appear in your Articles dashboard
- Manual: Click Generate in the Dashboard — AutoRanker pushes articles to your site immediately.
- Automated (Cron): Call
POST /api/v1/articles/generatefrom a scheduled script — articles are generated and pushed automatically on a schedule. See Cron Setup →
🚀 Push Flow
Here is the complete data flow from generation to your live blog:
AutoRanker Dashboard
↓ (click Generate)
AutoRanker API (generates articles + images with AI)
↓ POST /api/v1/publish
↓ Authorization: Bearer PUBLISH_API_KEY
↓ Payload: article data + imageBase64
Your Site (yoursite.com)
↓ validates AUTORANKER_API_KEY
↓ saves article + image to PostgreSQL
Live at: yoursite.com/blog/[slug]
Image at: yoursite.com/api/images/[slug]Key variables:
PUBLISH_API_KEYAUTORANKER_API_KEYDATABASE_URL🔄 Automated Generation via Cron
Instead of clicking Generate manually, you can call the AutoRanker API from a scheduled script (cron job). This lets you publish new articles automatically — for example, every 3 days.
- Your cron script calls
POST /api/v1/articles/generateon AutoRanker with a keyword - AutoRanker generates the article + image and returns it in the response
- Your script fetches the image and calls
POST /api/v1/publishon your own site - The article appears at
/blog/[slug]on your site - The API call is tracked in your API Usage dashboard
Generate Endpoint
https://autoranker.co/api/v1/articles/generate// Request
POST https://autoranker.co/api/v1/articles/generate
Authorization: Bearer YOUR_AUTORANKER_API_KEY
Content-Type: application/json
{
"keyword": "Large Language Models explained",
"length": "medium",
"articleType": "blog",
"domain": "yoursite.com",
"includeImage": true
}
// Response
{
"id": "art_abc123",
"status": "completed",
"content": {
"title": "Large Language Models Explained: A Complete Guide",
"html": "<article>...</article>",
"excerpt": "Learn how LLMs work...",
"imageUrl": "data:image/png;base64,..." // base64 data URL — extract directly
},
"metadata": {
"wordCount": 1450,
"keyword": "Large Language Models explained"
}
}The imageUrl in the response is a base64 data URL (data:image/png;base64,...). Extract the base64 part directly — no HTTP fetch needed. Pass it as imageBase64 to your publish endpoint where it will be stored in PostgreSQL.
Complete Cron Script (Node.js)
Save this as scripts/generate-articles.js in your site's repo:
#!/usr/bin/env node
// Rotate through topics — one article per run
const TOPICS = [
'Large Language Models explained',
'Generative AI for business',
'SaaS startups and AI trends',
'Artificial Intelligence tools 2025',
'Web development with AI',
]
const fs = require('fs')
const path = require('path')
const STATE_FILE = path.join(__dirname, '.generate-state.json')
function loadState() {
try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) }
catch { return { lastTopicIndex: -1 } }
}
async function run() {
const AUTORANKER_KEY = process.env.AUTORANKER_API_KEY
const SITE_URL = process.env.SITE_URL || 'https://yoursite.com'
if (!AUTORANKER_KEY) { console.error('Missing AUTORANKER_API_KEY'); process.exit(1) }
// Pick next topic in rotation
const state = loadState()
const nextIndex = (state.lastTopicIndex + 1) % TOPICS.length
const keyword = TOPICS[nextIndex]
console.log('Generating article for:', keyword)
// 1. Generate article via AutoRanker API
const genRes = await fetch('https://autoranker.co/api/v1/articles/generate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${AUTORANKER_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ keyword, length: 'medium', articleType: 'blog', domain: new URL(SITE_URL).hostname, includeImage: true }),
})
if (!genRes.ok) { console.error('Generate failed:', await genRes.text()); process.exit(1) }
const data = await genRes.json()
if (data.status === 'failed') { console.error('Generation failed:', data.message); process.exit(1) }
console.log('Generated:', data.content?.title, '(' + data.metadata?.wordCount + ' words)')
// 2. Extract image base64 — imageUrl is already a data URL, no HTTP fetch needed
let imageBase64 = null
const rawImageUrl = data.content?.imageUrl
if (rawImageUrl?.startsWith('data:')) {
const match = rawImageUrl.match(/^data:[^;]+;base64,(.+)$/)
if (match) {
imageBase64 = match[1]
console.log('Image extracted (' + Math.round(imageBase64.length / 1024) + 'KB)')
}
}
// 3. Build slug from title
const slug = data.content?.title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
// 4. Publish to your site
const pubRes = await fetch(`${SITE_URL}/api/v1/publish`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${AUTORANKER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
article: {
id: data.id, title: data.content?.title, slug,
content: data.content?.html, metaDescription: data.content?.excerpt,
articleType: 'blog', status: 'published',
wordCount: data.metadata?.wordCount || 0,
primaryKeyword: keyword, createdAt: new Date().toISOString(),
},
imageBase64,
}),
})
if (!pubRes.ok) { console.error('Publish failed:', await pubRes.text()); process.exit(1) }
console.log('Published to', SITE_URL + '/blog/' + slug)
// 5. Save state for next run
fs.writeFileSync(STATE_FILE, JSON.stringify({ lastTopicIndex: nextIndex, lastRun: new Date().toISOString() }, null, 2))
}
run().catch(err => { console.error(err); process.exit(1) })Deploy as Railway Cron Service
Create a separate cron service in Railway that runs your script on a schedule:
- In Railway, open your project → + New → Empty Service → name it e.g.
article-cron - Connect it to the same GitHub repo as your site
- In the service Settings:
- Custom Build Command:
echo "no build" - Custom Start Command:
node scripts/generate-articles.js - Cron Schedule:
0 9 */3 * *(every 3 days at 9am)
- Custom Build Command:
- Add environment variables to the cron service:
AUTORANKER_API_KEY = sk_prod_YOUR_KEY_HERE SITE_URL = https://yoursite.com DATABASE_URL = postgresql://... # same as your main service - Deploy — Railway will run the script on schedule. You can also trigger it immediately with Run now in the Deployments tab.
0 9 */3 * * — every 3 days at 9am (recommended for Starter plan)
0 9 * * * — every day at 9am
0 9 * * 1 — every Monday at 9am📊 API Dashboard
Every call to POST /api/v1/articles/generate is tracked in your API Usage dashboard at /dashboard/api.
What you'll see:
- Monthly API Calls — how many articles you've generated via API this month vs. your plan limit
- Resets on [date] — your subscription renewal date (30 days from signup, not the 1st of the month)
- Active API Keys — keys you've created in Settings → API Keys
- Success Rate & Avg Response Time — generation performance stats
Each call to /api/v1/articles/generate counts as one article against your monthly limit. For the Starter plan (10 articles/month) with a cron every 3 days, you'll use ~10 articles/month — exactly at the limit.
How to create an API key:
- Go to Dashboard → Settings → API Keys
- Click Create API Key → enter a name (e.g.
txt-llms.com) - Copy the key (shown only once!) and save it as
AUTORANKER_API_KEYin your site's environment variables
📁 Required Files
Your Next.js site needs these 8 files to receive and display articles from AutoRanker:
1. prisma/schema.prisma — Database Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Article {
id String @id @default(uuid())
title String
slug String @unique
content String @db.Text
metaDescription String? @db.Text
featuredImage String?
imageData String? @db.Text // base64 image stored in DB
articleType String @default("blog")
status String @default("published")
wordCount Int
primaryKeyword String?
siteUrl String?
generatedVia String @default("api")
createdAt DateTime @default(now())
publishedAt DateTime?
updatedAt DateTime @updatedAt
@@index([siteUrl])
@@index([slug])
}2. lib/storage/articles-store.ts — Storage Abstraction
Handles both PostgreSQL and JSON fallback. Key logic:
import { PrismaClient } from '@prisma/client'
import fs from 'fs/promises'
import path from 'path'
// Use DB if DATABASE_URL is set, otherwise fallback to JSON
const useDb = !!process.env.DATABASE_URL
const prisma = useDb ? new PrismaClient() : null
const STORAGE_PATH = path.join(process.cwd(), 'data', 'articles.json')
export async function saveArticle(article: StoredArticle): Promise<void> {
if (useDb && prisma) {
await prisma.article.upsert({
where: { slug: article.slug },
update: { ...article },
create: { ...article }
})
return
}
// JSON fallback (WARNING: data lost on redeploy!)
const articles = await getAllArticles()
const existing = articles.findIndex(a => a.slug === article.slug)
if (existing >= 0) articles[existing] = article
else articles.push(article)
await fs.writeFile(STORAGE_PATH, JSON.stringify(articles, null, 2))
}
export async function getArticleBySlug(slug: string) {
if (useDb && prisma) {
return await prisma.article.findUnique({ where: { slug } })
}
const articles = await getAllArticles()
return articles.find(a => a.slug === slug) || null
}3. app/api/v1/publish/route.ts — Publish Endpoint
import { NextRequest, NextResponse } from 'next/server'
import { saveArticle } from '@lib/storage/articles-store'
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('authorization')
const serverKey = process.env.AUTORANKER_API_KEY?.trim()
if (!authHeader || authHeader.trim() !== `Bearer ${serverKey}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const data = await req.json()
const article = data.article
const imageBase64 = data.imageBase64
// Store image in DB and set featuredImage to API route
if (imageBase64) {
article.imageData = imageBase64
article.featuredImage = `/api/images/${article.slug}`
}
// Ensure wordCount
if (!article.wordCount && article.content) {
article.wordCount = article.content.split(/\s+/).length
}
await saveArticle({ ...article, status: 'published' })
return NextResponse.json({ success: true })
} catch (e) {
return NextResponse.json({ error: 'Database Error' }, { status: 500 })
}
}3b. app/api/images/[slug]/route.ts — Image Serving
import { NextResponse } from 'next/server'
import prisma from '@lib/prisma'
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
const article = await prisma.article.findUnique({
where: { slug },
select: { imageData: true }
})
if (!article?.imageData) {
return new NextResponse('Not found', { status: 404 })
}
const buffer = Buffer.from(article.imageData, 'base64')
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable'
}
})
}4. app/blog/page.tsx — Blog List Page
import { getArticles } from '@lib/storage/articles-store'
export default async function BlogPage() {
const articles = await getArticles({ status: 'published' })
return (
<main>
<h1>Blog</h1>
{articles.map(article => (
<article key={article.id}>
<a href={`/blog/${article.slug}`}>{article.title}</a>
</article>
))}
</main>
)
}5. app/blog/[slug]/page.tsx — Article Page + SEO Metadata
import { notFound } from 'next/navigation'
import { Metadata } from 'next'
import { getArticleBySlug } from '@lib/storage/articles-store'
export const dynamic = 'force-dynamic'
// SEO: generates <title> and <meta description> for each article
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
const article = await getArticleBySlug(slug)
if (!article) return { title: 'Article Not Found' }
return {
title: article.title,
description: article.metaDescription || undefined,
openGraph: {
title: article.title,
description: article.metaDescription || undefined,
type: 'article',
...(article.featuredImage ? { images: [{ url: article.featuredImage }] } : {})
}
}
}
// Next.js 15: params is a Promise and must be awaited!
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const article = await getArticleBySlug(slug)
if (!article) return notFound()
return (
<article>
{article.featuredImage && (
<img src={article.featuredImage} alt={article.title} />
)}
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.content }} />
</article>
)
}The generateMetadata function sets <title>, <meta name="description">, and OpenGraph tags for each article. Without it, search engines and social media won't display proper titles and descriptions for your pages.
In Next.js 15, params in dynamic routes is a Promise and must be awaited. Using params.slug directly will return undefined, causing 404 errors.
6. package.json — Scripts
{
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "prisma db push --accept-data-loss && next start"
}
}prisma db push in start?Railway runs npm start after each deploy. prisma db push creates/syncs the database tables automatically. Without this, the app starts but the Article table doesn't exist yet.
7. .env.local (local) or Railway Variables (production)
# Your site's environment variables
AUTORANKER_API_KEY=sk_prod_YOUR_KEY_HERE
DATABASE_URL=postgresql://postgres:[email protected]:5432/railwayThe .env.local file is in .gitignore and never pushed to GitHub. You must set AUTORANKER_API_KEY and DATABASE_URL directly in your hosting provider's environment variables (Railway Variables, Vercel Environment Variables, etc.)
🔧 Environment Variables
On Your Site (txt-llms.com or your Next.js app)
AUTORANKER_API_KEY requiredPUBLISH_API_KEY in AutoRanker exactly (case-sensitive!).DATABASE_URLdata/articles.json (lost on redeploy). Strongly recommended for production.In AutoRanker (this app)
PUBLISH_API_KEY requiredAuthorization: Bearer header when pushing articles. Set in .env.local of this project.If PUBLISH_API_KEY in AutoRanker is sk_prod_abc123, then AUTORANKER_API_KEY on your site must also be exactly sk_prod_abc123. Even one character difference causes 401 errors.
Where to set them:
# Option 1: .env.local (local development only, NOT deployed)
AUTORANKER_API_KEY=sk_prod_YOUR_KEY_HERE
DATABASE_URL=postgresql://...
# Option 2: Railway Dashboard
# Go to: Project → Service → Variables → + New Variable
# Option 3: Vercel Dashboard
# Go to: Project → Settings → Environment Variables
# Option 4: VPS / Docker
# Add to your docker-compose.yml or systemd service file🗄️ Database Setup
PostgreSQL is required for production. Articles persist across deploys.
Option A: Railway PostgreSQL (easiest)
- In Railway, open your project
- Click + New → Database → PostgreSQL
- Railway automatically adds
DATABASE_URLto your service variables - The internal URL format:
postgresql://postgres:[email protected]:5432/railway - Redeploy your service —
prisma db pushwill create the tables
Option B: Supabase (free tier)
- Create a project at supabase.com
- Go to Settings → Database → Connection String
- Copy the URI and set it as
DATABASE_URL - Run
npx prisma db pushlocally (with the public URL)
Option C: Any PostgreSQL
# Set DATABASE_URL in your environment
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE
# Then run (once) to create tables:
npx prisma db push
# Or use migrations:
npx prisma migrate deployWhen DATABASE_URL is not set, articles are saved to data/articles.json. This file is wiped on every redeploy on Railway, Vercel, or any container-based hosting. All articles will be lost.
🚂 Deploy to Railway
Full step-by-step guide to deploy your Next.js site on Railway with PostgreSQL.
Step 1: Push your code to GitHub
cd your-nextjs-site
git init
git add -A
git commit -m "initial commit"
git remote add origin [email protected]:YOUR_USERNAME/YOUR_REPO.git
git push -u origin mainStep 2: Create Railway project
- Go to railway.com → New Project
- Select Deploy from GitHub repo
- Choose your repository
- Railway auto-detects Next.js and sets up the build
Step 3: Add PostgreSQL
- In your project, click + New → Database → Add PostgreSQL
- Railway automatically links it and adds
DATABASE_URLto your service
Step 4: Set environment variables
Go to your service → Variables → add:
AUTORANKER_API_KEY = sk_prod_YOUR_KEY_HERE
# DATABASE_URL is already added automatically by RailwayStep 5: Verify package.json start script
"start": "prisma db push --accept-data-loss && next start"This ensures database tables are created on every deploy.
Step 6: Deploy
git push origin main # Railway auto-deploys on pushStep 7: Generate articles
- Open AutoRanker Dashboard
- Set your domain to
yoursite.railway.app(or custom domain) - Click Generate
- Articles are pushed to your site and appear at
/blog/[slug] - View published links in Dashboard → Articles
In Railway → your service → Settings → Domains → add your custom domain. Update your domain's DNS to point to Railway.
▲ Deploy to Vercel / VPS
Vercel
- Push your code to GitHub
- Go to vercel.com → New Project → import your repo
- In Environment Variables, add:
AUTORANKER_API_KEY = sk_prod_YOUR_KEY_HERE DATABASE_URL = postgresql://... - Deploy
- Run migrations once:
npx prisma db push(with your production DATABASE_URL)
Vercel's filesystem is read-only. JSON fallback will NOT work on Vercel. You must use PostgreSQL (Supabase, Neon, or any external PostgreSQL).
VPS (Ubuntu/Debian)
# Clone your repo
git clone [email protected]:YOUR_USERNAME/YOUR_REPO.git
cd YOUR_REPO
# Set environment variables
echo "AUTORANKER_API_KEY=sk_prod_YOUR_KEY_HERE" >> .env
echo "DATABASE_URL=postgresql://..." >> .env
# Install and build
npm install
npx prisma db push
npm run build
# Start with PM2
npm install -g pm2
pm2 start "npm start" --name "my-blog"
pm2 save📦 Setup Without GitHub
If you don't want to use GitHub, you can deploy directly via Railway CLI or upload files manually.
Option A: Railway CLI (no GitHub needed)
# Install Railway CLI
npm install -g @railway/cli
# Login
railway login
# Create new project
railway init
# Link to existing project
railway link
# Set environment variables
railway variables set AUTORANKER_API_KEY=sk_prod_YOUR_KEY_HERE
railway variables set DATABASE_URL=postgresql://...
# Deploy directly from local folder
railway upOption B: Manual upload to VPS
# Build locally
npm run build
# Upload to server via SCP
scp -r .next package.json public user@your-server:/var/www/myblog/
# On the server
cd /var/www/myblog
npm install --production
AUTORANKER_API_KEY=sk_prod_... DATABASE_URL=postgresql://... npm startOption C: Docker
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install && npm run build
EXPOSE 3000
CMD ["npm", "start"]
# docker-compose.yml
version: '3'
services:
web:
build: .
environment:
- AUTORANKER_API_KEY=sk_prod_YOUR_KEY_HERE
- DATABASE_URL=postgresql://postgres:password@db:5432/blog
ports:
- "3000:3000"
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: blog📡 POST /api/v1/publish
AutoRanker calls this endpoint on your site to publish each generated article.
https://yoursite.com/api/v1/publishRequest Headers
Authorization requiredBearer YOUR_AUTORANKER_API_KEYContent-Type requiredapplication/jsonRequest Body
{
"article": {
"id": "uuid",
"title": "Best AI Tools in 2025",
"slug": "best-ai-tools-2025",
"content": "<article>...</article>",
"metaDescription": "Discover the best AI tools...",
"featuredImage": "/api/images/best-ai-tools-2025",
"articleType": "blog",
"wordCount": 1450,
"primaryKeyword": "ai tools",
"siteUrl": "yoursite.com",
"generatedVia": "api"
},
"imageBase64": "iVBORw0KGgo..." // optional, base64-encoded PNG
}When imageBase64 is provided, the image is stored in PostgreSQL and served via /api/images/[slug]. Images persist across deploys — no external storage needed.
Response
// Success
{ "success": true }
// Error - wrong API key
{ "error": "Unauthorized" } // 401
// Error - database issue
{ "error": "Database Error" } // 500Example curl test
curl -X POST https://yoursite.com/api/v1/publish \
-H "Authorization: Bearer sk_prod_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"article": {
"id": "test-123",
"title": "Test Article",
"slug": "test-article",
"content": "<p>Hello world</p>",
"articleType": "blog",
"wordCount": 2,
"siteUrl": "yoursite.com",
"generatedVia": "api"
}
}'
# Expected: {"success":true}
# Then visit: https://yoursite.com/blog/test-article📡 POST /api/v1/articles/generate
Generates a single SEO article with an optional featured image.
https://autoranker.co/api/v1/articles/generateRequest Headers
Authorization requiredBearer YOUR_AUTORANKER_API_KEYContent-Type requiredapplication/jsonRequest Body
keyword required"Large Language Models explained"length"short" | "medium" | "long". Default: "medium" (~1200–1600 words)articleType"blog" | "seo". Default: "blog"domain"yoursite.com"includeImagetrue. Adds ~10s to generation time.Response
{
"id": "art_abc123",
"status": "completed", // "completed" | "failed"
"content": {
"title": "Large Language Models Explained",
"html": "<article><h2>...</h2>...</article>",
"excerpt": "Learn how LLMs work and why they matter...",
"imageUrl": "data:image/png;base64,..." // base64 data URL
},
"metadata": {
"wordCount": 1450,
"keyword": "Large Language Models explained",
"generatedAt": "2026-03-26T09:00:00.000Z"
},
"message": "Article generated successfully"
}
// On failure:
{ "status": "failed", "message": "Rate limit exceeded" }Article generation takes approximately 30–60 seconds. Make sure your HTTP client timeout is set to at least 90 seconds. The Railway cron service handles this correctly by default.
⚠️ Error Codes
API keys don't match. Check that PUBLISH_API_KEY in AutoRanker equals AUTORANKER_API_KEY on your site (case-sensitive).
Article was pushed but not saved to DB. Usually means DATABASE_URL is missing and JSON fallback was used — data was lost on redeploy. Add PostgreSQL.
Database connection failed. Check DATABASE_URL is correct and prisma db push has been run to create tables.
The blog page is calling prisma.article.findUnique directly instead of getArticleBySlug(). Replace with the storage abstraction function.