Developer guide

Connect your site to Perennial

Everything your developer needs to make your custom site a Perennial publishing destination: the endpoints, the post shape, reference code, and the exact checks we run. Connecting your own site? The ready-to-paste prompt for your AI builder is in the app under Settings → Account.

Fastest path

1. Paste a prompt into your AI builder

Built the site with an AI builder or coding agent (Cursor, Lovable, Bolt, v0, Replit, Claude Code, Codex, or similar)? A ready-to-paste prompt that adds these endpoints for you lives in the Perennial dashboard.

In Perennial, open Settings → Account → Custom / AI-built site and copy the install prompt there, then paste it into your builder. Prefer to wire it up by hand? The endpoints, post shape, and reference code below are the full spec.

Sign in to Perennial →

What gets built

2. The endpoints

Six JSON endpoints under /api/perennial/v1, all behind one bearer token. Start with posts, add the rest later.

EndpointDoes
GET /healthConfirms auth + declares capabilities
GET /postsList / look up by slug or url
GET /posts/:idFetch one post
POST /postsCreate a post, return it with its id
PUT /posts/:idUpdate a post
DELETE /posts/:idDelete a post

The Post object every endpoint reads/returns:

Post shape (JSON)
{
  "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"
}
Copy-paste starting point

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.

Next.js, app/api/perennial/v1/[[...path]]/route.ts
// 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') },
  async remove(id: string) { throw new Error('store.remove 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()))
    if (req.method === 'DELETE') { if (!(await store.get(id))) return NextResponse.json({ error: 'Not found' }, { status: 404 }); await store.remove(id); return new NextResponse(null, { status: 204 }) }
  }
  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)
Definition of done

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. Verify is read-only: the write endpoints (POST, PUT, DELETE) aren’t exercised during Verify, and until a post exists GET /posts can only warn that the shape is unverified, which is harmless. Don’t seed a placeholder post to clear it; implement the write endpoints to this spec and the exact shape is confirmed on the first real post Perennial publishes.

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); [] when empty, never 404
  • GET /posts supports ?slug=, ?url=, and ?limit=/?offset= paging (default limit 100)
  • GET /posts/:id returns the post, or 404 if it doesn’t exist
  • POST /posts creates a post and returns the saved post including its id
  • PUT /posts/:id updates only the fields sent and returns the saved post
  • DELETE /posts/:id deletes the post (200/204), or 404 if the id is unknown
  • Ids are stable, the id returned from POST is what GET/PUT/DELETE /posts/:id use

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
Connect

5. Verify & go live

  1. Deploy with PERENNIAL_API_TOKEN set to a long random string (openssl rand -hex 32).
  2. In Perennial → Settings → Account → Custom / AI-built site, enter the base URL, base path (/api/perennial/v1), and token.
  3. 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.