Docs · Autopilot integrations
Hosted blog for Next.js
We host your articles; your Next.js site reads them with one API key. Set it up once and every article Autopilot publishes appears at /blog. No GitHub access, no pull requests, no redeploys.
Prefer articles as files in your repo? Use the GitHub pull request integration instead. Another framework? Read the same API from any server, or use the signed webhook.
Setup
Four steps, about ten minutes
- 01
Copy the starter into your app
Download the zip or copy the files below.lib/andapp/merge into your project (undersrc/if your app lives insrc/app). Needs Next.js 15 or newer with the App Router. - 02
Create an API key
In Autopilot: Settings → Where articles go → Next.js blog → Create API key. Add it asLAUNCHRANKED_API_KEY, plusSITE_URL, in.env.localand your host's environment settings. Keep it server-side: never prefix it withNEXT_PUBLIC_. - 03
Deploy oncethe only deploy
Your blog is live at /blog. From now on, articles appear as they publish. - 04
Connect the refresh route
Back in settings, set the post URL pattern tohttps://example.com/blog/{slug}and the revalidate URL tohttps://example.com/api/launchranked/revalidate, then press Test connection. New articles now show up within seconds instead of within the hour.
Starter files
Everything the blog needs
Plain Next.js code you own: restyle the CSS module or swap in your own components. Pages cache for an hour and refresh on the publish ping.
README.md
# LaunchRanked blog for Next.js
Your Autopilot articles at `/blog`, served from LaunchRanked. Set it up once; after that every published article shows up on its own: no pull requests, no redeploys.
Needs Next.js 15 or newer with the App Router.
## Setup
1. Copy the folders into your project, merging with what's there (in `src/` if your app lives in `src/app`):
- `lib/launchranked.ts`
- `app/blog/` (index, article pages, sitemap)
- `app/api/launchranked/revalidate/route.ts`
2. In LaunchRanked: **Settings → Where articles go → Next.js blog → Create API key**. Add it to your environment (`.env.local` and your host's settings):
```
LAUNCHRANKED_API_KEY=lrb_...
SITE_URL=https://example.com
```
Keep the key server-side. Never prefix it with `NEXT_PUBLIC_`.
3. Deploy, then back in LaunchRanked set:
- **Post URL pattern**: `https://example.com/blog/{slug}`
- **Revalidate URL**: `https://example.com/api/launchranked/revalidate`
and press **Test connection**.
4. Add `https://example.com/blog/sitemap.xml` to your `robots.txt` or submit it in Search Console.
## How it works
- Pages fetch from `https://launchranked.com/api/v1/blog` on the server and are cached for an hour.
- When an article publishes, LaunchRanked sends a signed POST to the revalidate route, which refreshes `/blog` right away.
- Article HTML is sanitized by LaunchRanked before it's served. Images are hosted on launchranked.com (no `next.config` change needed).
- If the API can't be reached during `next build`, the blog builds empty instead of failing your deploy, and fills in on the next refresh.
Restyle `app/blog/blog.module.css`, or swap the markup for your own components. It's your code.
## API
`GET /api/v1/blog/posts?page=1&limit=20` and `GET /api/v1/blog/posts/{slug}`, with `Authorization: Bearer <LAUNCHRANKED_API_KEY>`. Full reference: https://launchranked.com/docs/nextjs-blog
lib/launchranked.ts
/**
* LaunchRanked hosted blog client. Server-only: it reads LAUNCHRANKED_API_KEY,
* so never import it from a "use client" file.
*
* Env:
* LAUNCHRANKED_API_KEY required, from Autopilot → Settings → Where articles go → Next.js blog
* SITE_URL optional, e.g. https://example.com (absolute URLs when no post URL pattern is set)
*/
const API_URL = (process.env.LAUNCHRANKED_API_URL || "https://launchranked.com").replace(/\/+$/, "");
export const SITE_URL = (process.env.SITE_URL || "").replace(/\/+$/, "");
/** Fallback refresh time. With the revalidate route set up, new articles show up within seconds. */
export const REVALIDATE_SECONDS = 3600;
export type PostSummary = {
slug: string;
title: string;
description: string;
image: { url: string; alt: string } | null;
publishedAt: string | null;
updatedAt: string;
/** Live URL from your post URL pattern, when set. */
url: string | null;
};
export type Post = PostSummary & {
/** Sanitized body HTML (no title). */
html: string;
markdown: string;
faqs: { q: string; a: string }[];
faqJsonLd: Record<string, unknown> | null;
sources: { url: string; title?: string }[];
wordCount: number;
};
export type PostPage = { posts: PostSummary[]; page: number; limit: number; total: number; totalPages: number };
// During `next build` an API hiccup (or a missing key) shouldn't fail your whole deploy:
// the blog builds empty and fills in on the next revalidation.
const building = process.env.NEXT_PHASE === "phase-production-build";
async function get<T>(path: string): Promise<T | null> {
const key = process.env.LAUNCHRANKED_API_KEY;
if (!key) throw new Error("LAUNCHRANKED_API_KEY is not set.");
const res = await fetch(`${API_URL}/api/v1/blog${path}`, {
headers: { Authorization: `Bearer ${key}` },
next: { revalidate: REVALIDATE_SECONDS, tags: ["launchranked"] },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`LaunchRanked API answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
async function orEmpty<T>(load: () => Promise<T>, empty: T): Promise<T> {
if (!building) return load();
try {
return await load();
} catch (err) {
console.warn(`[launchranked] ${err instanceof Error ? err.message : err} (building the blog empty)`);
return empty;
}
}
export function getPosts(page = 1, limit = 12): Promise<PostPage> {
return orEmpty(async () => (await get<PostPage>(`/posts?page=${page}&limit=${limit}`)) ?? { posts: [], page, limit, total: 0, totalPages: 0 }, {
posts: [],
page,
limit,
total: 0,
totalPages: 0,
});
}
export function getPost(slug: string): Promise<Post | null> {
return orEmpty(async () => (await get<{ post: Post }>(`/posts/${encodeURIComponent(slug)}`))?.post ?? null, null);
}
/** Every published post (for the sitemap). */
export async function getAllPosts(): Promise<PostSummary[]> {
const all: PostSummary[] = [];
for (let page = 1; page <= 50; page++) {
const r = await getPosts(page, 100);
all.push(...r.posts);
if (page >= r.totalPages) break;
}
return all;
}
/** Absolute URL of a post: your post URL pattern, else SITE_URL + /blog/slug, else a relative path. */
export const postUrl = (p: Pick<PostSummary, "slug" | "url">) => p.url ?? `${SITE_URL}/blog/${p.slug}`;
export const formatDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" }) : "";
app/blog/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { formatDate, getPosts } from "../../lib/launchranked";
import styles from "./blog.module.css";
export const metadata: Metadata = {
title: "Blog",
description: "Guides and articles from our team.",
};
type Props = { searchParams: Promise<{ page?: string }> };
export default async function BlogIndex({ searchParams }: Props) {
const page = Math.max(1, Number((await searchParams).page) || 1);
const { posts, totalPages } = await getPosts(page, 12);
return (
<main className={styles.wrap}>
<h1 className={styles.heading}>Blog</h1>
{posts.length === 0 ? (
<p className={styles.muted}>No articles yet.</p>
) : (
<ul className={styles.grid}>
{posts.map((p) => (
<li key={p.slug} className={styles.card}>
<Link href={`/blog/${p.slug}`} className={styles.cardLink}>
{p.image && (
// eslint-disable-next-line @next/next/no-img-element -- hosted on launchranked.com; no next.config change needed
<img src={p.image.url} alt={p.image.alt} className={styles.cardImage} loading="lazy" width={1536} height={1024} />
)}
<h2 className={styles.cardTitle}>{p.title}</h2>
<p className={styles.muted}>{p.description}</p>
{p.publishedAt && <time dateTime={p.publishedAt} className={styles.date}>{formatDate(p.publishedAt)}</time>}
</Link>
</li>
))}
</ul>
)}
{totalPages > 1 && (
<nav className={styles.pager} aria-label="Pagination">
{page > 1 ? <Link href={page === 2 ? "/blog" : `/blog?page=${page - 1}`}>← Newer</Link> : <span />}
<span className={styles.muted}>
Page {page} of {totalPages}
</span>
{page < totalPages ? <Link href={`/blog?page=${page + 1}`}>Older →</Link> : <span />}
</nav>
)}
</main>
);
}
app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { formatDate, getPost, postUrl } from "../../../lib/launchranked";
import styles from "../blog.module.css";
// Next.js needs a literal here: keep it equal to REVALIDATE_SECONDS in lib/launchranked.ts.
export const revalidate = 3600;
// Articles render on first visit and are cached; none are prebuilt.
export async function generateStaticParams() {
return [];
}
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost((await params).slug);
if (!post) return {};
const url = postUrl(post);
return {
title: post.title,
description: post.description,
alternates: url.startsWith("http") ? { canonical: url } : undefined,
openGraph: {
type: "article",
title: post.title,
description: post.description,
publishedTime: post.publishedAt ?? undefined,
modifiedTime: post.updatedAt,
images: post.image ? [{ url: post.image.url, alt: post.image.alt }] : undefined,
},
};
}
/** JSON for a <script> tag: "<" escaped so the content can't close it. */
const ld = (data: unknown) => ({ __html: JSON.stringify(data).replace(/</g, "\\u003c") });
export default async function BlogPost({ params }: Props) {
const post = await getPost((await params).slug);
if (!post) notFound();
const url = postUrl(post);
return (
<main className={styles.wrap}>
<article className={styles.article}>
<Link href="/blog" className={styles.back}>
← All articles
</Link>
<h1 className={styles.title}>{post.title}</h1>
{post.publishedAt && <time dateTime={post.publishedAt} className={styles.date}>{formatDate(post.publishedAt)}</time>}
{post.image && (
// eslint-disable-next-line @next/next/no-img-element -- hosted on launchranked.com; no next.config change needed
<img src={post.image.url} alt={post.image.alt} className={styles.hero} width={1536} height={1024} />
)}
{/* Sanitized by LaunchRanked: raw HTML from the writer is escaped, links are http(s) or relative. */}
<div className={styles.body} dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={ld({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.description,
image: post.image?.url,
datePublished: post.publishedAt ?? undefined,
dateModified: post.updatedAt,
...(url.startsWith("http") ? { mainEntityOfPage: url } : {}),
})}
/>
{post.faqJsonLd && <script type="application/ld+json" dangerouslySetInnerHTML={ld(post.faqJsonLd)} />}
</main>
);
}
app/blog/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts, postUrl } from "../../lib/launchranked";
// Next.js needs a literal here: keep it equal to REVALIDATE_SECONDS in lib/launchranked.ts.
export const revalidate = 3600;
/** Served at /blog/sitemap.xml. Add it to robots.txt or submit it in Search Console. */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return posts
.map((p) => ({ url: postUrl(p), lastModified: p.updatedAt }))
.filter((e) => e.url.startsWith("http")); // sitemaps need absolute URLs: set SITE_URL or a post URL pattern
}
app/blog/blog.module.css
/* Plain defaults that inherit your site's font and colors. Restyle freely. */
.wrap {
max-width: 1080px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.heading {
font-size: 2.25rem;
line-height: 1.15;
margin: 0 0 32px;
}
.muted {
opacity: 0.72;
margin: 0;
}
.date {
display: block;
font-size: 0.875rem;
opacity: 0.6;
margin-top: 8px;
}
.grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 28px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.cardLink {
color: inherit;
text-decoration: none;
display: block;
}
.cardImage {
width: 100%;
height: auto;
aspect-ratio: 3 / 2;
object-fit: cover;
border-radius: 12px;
margin-bottom: 14px;
}
.cardTitle {
font-size: 1.2rem;
line-height: 1.3;
margin: 0 0 6px;
}
.cardLink:hover .cardTitle {
text-decoration: underline;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 48px;
}
.article {
max-width: 720px;
margin: 0 auto;
}
.back {
font-size: 0.875rem;
color: inherit;
opacity: 0.7;
}
.title {
font-size: clamp(2rem, 5vw, 2.75rem);
line-height: 1.12;
margin: 20px 0 0;
}
.hero {
width: 100%;
height: auto;
border-radius: 14px;
margin: 28px 0 8px;
}
.body {
font-size: 1.075rem;
line-height: 1.75;
}
.body h2 {
font-size: 1.6rem;
line-height: 1.25;
margin: 2.2em 0 0.6em;
}
.body h3 {
font-size: 1.25rem;
margin: 1.8em 0 0.5em;
}
.body img {
max-width: 100%;
height: auto;
border-radius: 10px;
}
.body a {
color: inherit;
text-underline-offset: 3px;
}
.body table {
width: 100%;
border-collapse: collapse;
display: block;
overflow-x: auto;
}
.body th,
.body td {
border: 1px solid rgba(127, 127, 127, 0.3);
padding: 8px 10px;
text-align: left;
}
.body pre {
overflow-x: auto;
padding: 14px;
border-radius: 10px;
background: rgba(127, 127, 127, 0.12);
}
.body blockquote {
margin: 1.5em 0;
padding-left: 1em;
border-left: 3px solid rgba(127, 127, 127, 0.4);
}
app/api/launchranked/revalidate/route.ts
import { revalidatePath } from "next/cache";
/**
* LaunchRanked calls this after each publish (set it as the Revalidate URL in
* Autopilot settings) so new articles show up in seconds, not at the next hourly refresh.
* The body is signed with your API key: X-LaunchRanked-Signature = hex HMAC-SHA256(body, key).
*/
export async function POST(req: Request) {
const key = process.env.LAUNCHRANKED_API_KEY;
const body = await req.text();
if (!key || !(await validSignature(body, req.headers.get("x-launchranked-signature") ?? "", key))) {
return Response.json({ ok: false, error: "Bad signature." }, { status: 401 });
}
const event = JSON.parse(body) as { event?: string; slug?: string };
if (event.event === "article.published") {
revalidatePath("/blog", "layout");
revalidatePath("/blog/sitemap.xml");
}
return Response.json({ ok: true });
}
async function validSignature(body: string, signature: string, key: string): Promise<boolean> {
const k = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(body)));
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
if (signature.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
return diff === 0;
}
API reference
Two read endpoints and one ping
Authentication
Send Authorization: Bearer <key>. Keys are per site, start with lrb_, and are read-only. Replacing a key in settings stops the old one right away. A missing or wrong key gets a 401. Call the API from your server, never from the browser.
Try it
curl https://launchranked.com/api/v1/blog/posts?limit=5 \
-H "Authorization: Bearer $LAUNCHRANKED_API_KEY"GET /api/v1/blog/posts?page=1&limit=20
Published articles, newest first. limit is 1–100. url comes from your post URL pattern; use it as the canonical.
200 OK
{
"ok": true,
"posts": [
{
"slug": "best-crm-for-startups",
"title": "Best CRM for startups in 2026",
"description": "We compared 9 CRMs on price, setup time and…",
"image": { "url": "https://launchranked.com/media/autopilot/…/….png", "alt": "A small team around a pipeline board" },
"publishedAt": "2026-09-24T09:12:00.000Z",
"updatedAt": "2026-09-24T09:12:00.000Z",
"url": "https://example.com/blog/best-crm-for-startups"
}
],
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}GET /api/v1/blog/posts/{slug}
One article with its body. html has no title (render your own <h1>) and is sanitized: raw HTML from the writer is escaped and links are http(s) or relative only. A slug that isn't published returns 404.
200 OK
{
"ok": true,
"post": {
"slug": "best-crm-for-startups",
"title": "Best CRM for startups in 2026",
"description": "…",
"image": { "url": "…", "alt": "…" },
"publishedAt": "…",
"updatedAt": "…",
"url": "https://example.com/blog/best-crm-for-startups",
"html": "<p>…</p><h2>…</h2>…",
"markdown": "…",
"faqs": [{ "q": "…", "a": "…" }],
"faqJsonLd": { "@context": "https://schema.org", "@type": "FAQPage", … },
"sources": [{ "url": "https://…", "title": "…" }],
"wordCount": 1480
}
}Publish ping
With a revalidate URL set, we POST this after each article goes live (and a ping event from Test connection). Check the signature with your API key before trusting it; the starter's route does. A failed ping never unpublishes anything: the article still appears when your cache expires.
Request
POST <your revalidate URL>
Content-Type: application/json
X-LaunchRanked-Event: article.published
X-LaunchRanked-Signature: <hex HMAC-SHA256 of the raw body, keyed with your API key>
{ "event": "article.published", "slug": "best-crm-for-startups", "sentAt": "…", "site": { "id": "…", "domain": "example.com" } }