Connect your site to Perennial
Everything your developer (or your AI builder) needs to make your custom site a Perennial publishing destination. Send them this page, or paste the prompt below into the tool you built the site with.
1. Paste this into your AI builder
Built the site with Cursor, Lovable, Bolt, v0, or Replit? Copy this and paste it in. It adds the required endpoints for you.
Add the "Perennial Content API v1" to my site so an external SEO tool can
publish and update blog posts.
Create these JSON endpoints under /api/perennial/v1, all requiring an
"Authorization: Bearer <token>" header (compare against an env var
PERENNIAL_API_TOKEN; return 401 on mismatch):
GET /health -> { ok: true, name, apiVersion: "1.0", siteUrl,
capabilities: { posts: true, pages: false,
taxonomy: false, authors: false,
redirects: false, media: "url-only" } }
GET /posts?slug=&url=&limit=&offset= -> array of posts
GET /posts/:id -> one post, or 404
POST /posts -> create a post, return it (201)
PUT /posts/:id -> update a post (only fields in the body), return it
A post is JSON:
{ id, title, slug, content (HTML string), link, status
("publish"|"draft"|"future"), date (ISO), modified (ISO), metaTitle,
metaDescription, contentType: "post", featuredImage: { src },
tags: string[], categories: string[], author }
Requirements:
- Back it with my existing database / content store.
- "siteUrl" is my public site origin; the API base path is /api/perennial/v1.
- Tags and categories are arrays of strings.
- Featured images are URL references (featuredImage.src), not uploads.
- Only declare a capability as true in /health once that endpoint really works.
When done, tell me: my API base URL, the base path, and the value to set for
PERENNIAL_API_TOKEN, I'll paste those into Perennial and click Verify.2. The endpoints
Five JSON endpoints under /api/perennial/v1, all behind one bearer token. Start with posts, add the rest later.
| Endpoint | Does |
|---|---|
| GET /health | Confirms auth + declares capabilities |
| GET /posts | List / look up by slug or url |
| GET /posts/:id | Fetch one post |
| POST /posts | Create a post |
| PUT /posts/:id | Update a post |
The Post object every endpoint reads/returns:
{
"id": "123", // string or number, stable
"title": "How to rank an AI site",
"slug": "rank-an-ai-site",
"content": "<p>HTML body…</p>", // HTML string
"link": "https://example.com/blog/rank-an-ai-site",
"status": "publish", // "publish" | "draft" | "future"
"date": "2026-06-06T12:00:00Z", // ISO 8601
"modified": "2026-06-06T12:00:00Z",
"metaTitle": "How to rank an AI-built site | Example",
"metaDescription": "A practical guide…",
"contentType": "post", // "post" | "page"
"featuredImage": { "src": "https://cdn.example.com/x.jpg", "alt": "…" },
"tags": ["seo", "ai"],
"categories": ["guides"],
"author": "Jane Doe"
}3. Reference implementation
A complete Next.js handler. Drop it in, set PERENNIAL_API_TOKEN, and wire the store to your database. Using another stack? The same contract works in Express, Hono, FastAPI, Rails, anything that speaks HTTP + JSON.
// app/api/perennial/v1/[[...path]]/route.ts (Next.js App Router)
// Set PERENNIAL_API_TOKEN in your env. Wire the `store` to your database.
import { NextRequest, NextResponse } from 'next/server'
export const dynamic = 'force-dynamic'
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'
const CAPABILITIES = { posts: true, pages: false, taxonomy: false,
authors: false, redirects: false, media: 'url-only' }
// TODO: replace with real DB calls, returning the normalized Post shape.
const store = {
async list(o: any) { return [] },
async get(id: string) { return null },
async create(input: any) { throw new Error('store.create not implemented') },
async update(id: string, input: any) { throw new Error('store.update not implemented') },
}
function authorized(req: NextRequest) {
const t = (req.headers.get('authorization') || '').replace('Bearer ', '')
return !!process.env.PERENNIAL_API_TOKEN && t === process.env.PERENNIAL_API_TOKEN
}
async function handle(req: NextRequest, params: { path?: string[] }) {
if (!authorized(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const seg = params.path || [], route = '/' + seg.join('/'), url = new URL(req.url)
if (route === '/health' && req.method === 'GET')
return NextResponse.json({ ok: true, name: 'My Site', apiVersion: '1.0', siteUrl: SITE_URL, capabilities: CAPABILITIES })
if (route === '/posts') {
if (req.method === 'GET') return NextResponse.json(await store.list({
slug: url.searchParams.get('slug'), url: url.searchParams.get('url'),
limit: Number(url.searchParams.get('limit') || 100), offset: Number(url.searchParams.get('offset') || 0) }))
if (req.method === 'POST') return NextResponse.json(await store.create({ ...(await req.json()), contentType: 'post' }), { status: 201 })
}
if (route.startsWith('/posts/')) {
const id = decodeURIComponent(seg[1] || '')
if (req.method === 'GET') { const p = await store.get(id); return p ? NextResponse.json(p) : NextResponse.json({ error: 'Not found' }, { status: 404 }) }
if (req.method === 'PUT' || req.method === 'PATCH') return NextResponse.json(await store.update(id, await req.json()))
}
return NextResponse.json({ error: `No handler for ${req.method} ${route}` }, { status: 404 })
}
export const GET = async (r: NextRequest, c: any) => handle(r, await c.params)
export const POST = async (r: NextRequest, c: any) => handle(r, await c.params)
export const PUT = async (r: NextRequest, c: any) => handle(r, await c.params)
export const PATCH = async (r: NextRequest, c: any) => handle(r, await c.params)
export const DELETE = async (r: NextRequest, c: any) => handle(r, await c.params)4. The acceptance checklist
Build every item below. When the site owner clicks “Verify” in Perennial, we automatically probe the read side, /health (auth + capabilities) and GET /posts, and confirm the capabilities you declared. The write endpoints (POST/PUT and GET /posts/:id) aren’t mutated during Verify; they’re exercised on your first publish from Perennial, so implement them to this spec.
Must pass
- GET /health returns 200 with { "ok": true } and an honest capabilities object
- The bearer token is checked, wrong/missing token returns 401
- GET /posts returns 200 and an array (posts with id, title, slug, content)
- GET /posts/:id returns the post, or 404 if it doesn’t exist
- POST /posts creates a post and returns it
- PUT /posts/:id updates only the fields sent and returns the post
Recommended (warnings, not blockers)
- health.siteUrl is set (your public origin), needed for clean post URLs
- Declare redirects so slug changes keep their Google ranking (SEO)
- Declare pages if you want service-page publishing too
- Declare taxonomy if you manage categories/tags server-side
5. Verify & go live
- Deploy with
PERENNIAL_API_TOKENset to a long random string (openssl rand -hex 32). - In Perennial → Settings → Account → Custom / AI-built site, enter the base URL, base path (
/api/perennial/v1), and token. - Click Verify connection. Each check above reports pass / warn / fail. Fix the fails (paste them back to your AI builder) and re-verify until it’s green.
No backend? Fully static site?
You don’t have to build any of this. Perennial can host your blog content and serve it to a static site. Talk to us and we’ll set it up.