Developer guide

Add the blog to a custom-built site

overrank writes and publishes a new SEO article for this business every day. This guide shows the person who built the site how to show those articles on it, whether it runs on Next.js, React, Vue, PHP or anything else. It takes about an hour, and after that every new article appears on its own.

Tip: add ?site=yourdomain.com to this page's address and every example uses that domain.

Two ways to do it

Option A

The 2-line embed

About 10 minutes. Paste two lines on one page and the blog appears there, every article included. Works on any site.

Option BRecommended

Your own pages from our content API

About an hour. Your server fetches the articles and renders them as normal pages in your design. Best for Google and Google AI Overviews, because the article text is right in the page's HTML.

Both show the same articles, and new ones appear by themselves. Pick one.

Option A: the 2-line embed

  1. Create an empty page at yourdomain.com/insights.
  2. Paste these two lines where the blog should appear. The script line goes after the div.
HTML
<div id="ms-blog-content"></div>
<script src="https://www.overrank.ai/embed.js" data-site="yourdomain.com" data-path="/insights"></script>
  1. Publish. The article list shows on that page, and each article opens at /insights?post=article-slug.
  • React, Next.js or Vue: a plain script tag inside a component does not run. Load it with your framework's script component (in Next.js, next/script with strategy="afterInteractive") on that page, and render the div on the same page.
  • That is all for Option A. Nothing else to set up.

1. Pick the address for the articles

Each article gets its own page, for example https://yourdomain.com/blog/article-slug, and the list lives at https://yourdomain.com/blog. Any path works. The examples use /blog, so change it there if you pick another one. Keep it the same once you go live, because Google remembers these addresses.

2. Get the articles

Two read-only requests. Every published article, newest first:

GEThttps://www.overrank.ai/api/articles/public?site=yourdomain.com
Response
{
  "articles": [
    {
      "id": "a1c3d0e2-...",
      "title": "How to Choose the Right ...",
      "slug": "how-to-choose-the-right-...",
      "excerpt": "A short summary for cards and previews.",
      "metaDescription": "For the page's meta description tag.",
      "thumbnailUrl": "https://images.pexels.com/photos/...jpeg",
      "publishedAt": "2026-10-03T13:05:00.000Z",
      "readingTime": 6
    }
  ],
  "site": { "name": "Your Business", "domain": "yourdomain.com" }
}

One full article:

GEThttps://www.overrank.ai/api/articles/public?site=yourdomain.com&slug=article-slug
Response
{
  "id": "a1c3d0e2-...",
  "title": "How to Choose the Right ...",
  "slug": "how-to-choose-the-right-...",
  "metaDescription": "For the page's meta description tag.",
  "content": "<p>Ready-to-render HTML ...</p><h2>...</h2>",
  "excerpt": "A short summary for cards and previews.",
  "thumbnailUrl": "https://images.pexels.com/photos/...jpeg",
  "publishedAt": "2026-10-03T13:05:00.000Z",
  "wordCount": 1180,
  "readingTime": 6,
  "faqs": [{ "question": "...", "answer": "..." }],
  "siteName": "Your Business",
  "ctaButton": { "enabled": true, "url": "https://yourdomain.com/", "label": "Get a quote" },
  "relatedArticles": [
    { "id": "...", "title": "...", "slug": "...", "thumbnailUrl": "...", "readingTime": 5 }
  ]
}

The full article also returns a few display fields the embed uses. You can ignore them.

  • No key needed. It is the same public content the live blog shows.
  • Call it from your server or at build time, not in the browser, so the article text is in the HTML that Google reads.
  • Cache the answers for about 15 minutes. New articles publish once a day.
  • content is ready-to-render HTML with no H1. Use title as the page's only H1.
  • faqs repeats the FAQ that is already inside content. Use it for FAQ structured data only, and don't print it twice.
  • ctaButton is the business's call-to-action button. Show it at the end of the article when enabled is true.
  • Images are full URLs, mostly on images.pexels.com. Use a plain img tag, or allow those hosts in your image settings.
  • An unknown slug answers 404. Show your normal page-not-found page.
  • Every field is described in the API reference.

3. Build the pages

A complete example for the Next.js App Router (Next.js 15 or newer). Keep your own layout and styling. What matters is fetching on the server and putting each field in the right place.

lib/overrank.ts
// overrank articles for yourdomain.com, from the public content API (no key needed).
const SITE = "yourdomain.com";
const API = "https://www.overrank.ai/api/articles/public";

export type ArticleSummary = {
  id: string;
  title: string;
  slug: string;
  excerpt: string | null;
  metaDescription: string | null;
  thumbnailUrl: string | null;
  publishedAt: string | null;
  readingTime: number | null;
};

export type Article = ArticleSummary & {
  content: string;
  wordCount: number | null;
  faqs: { question: string; answer: string }[];
  ctaButton?: { enabled: boolean; url: string; label: string };
  relatedArticles: { id: string; title: string; slug: string; thumbnailUrl: string | null }[];
};

// Every published article, newest first. Returns [] when the API cannot be
// reached, so the blog page still renders.
export async function getArticles(): Promise<ArticleSummary[]> {
  try {
    const res = await fetch(`${API}?site=${SITE}`, { next: { revalidate: 900 } });
    if (!res.ok) return [];
    const data: { articles?: ArticleSummary[] } = await res.json();
    return data.articles ?? [];
  } catch {
    return [];
  }
}

// One full article, or null when it does not exist (show your 404 page).
// Any other error throws, so the last good copy of the page keeps showing.
export async function getArticle(slug: string): Promise<Article | null> {
  const res = await fetch(`${API}?site=${SITE}&slug=${encodeURIComponent(slug)}`, {
    next: { revalidate: 900 },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`overrank API error ${res.status}`);
  return res.json();
}
app/blog/page.tsx
import Link from "next/link";
import type { Metadata } from "next";
import { getArticles } from "@/lib/overrank";

export const revalidate = 900;

export const metadata: Metadata = {
  title: "Blog",
  alternates: { canonical: "https://yourdomain.com/blog" },
};

export default async function BlogPage() {
  const articles = await getArticles();
  return (
    <main>
      <h1>Blog</h1>
      {articles.map((a) => (
        <article key={a.id}>
          {a.thumbnailUrl && (
            <img src={a.thumbnailUrl} alt={a.title} width={640} height={360} loading="lazy" />
          )}
          <h2>
            <Link href={`/blog/${a.slug}`}>{a.title}</Link>
          </h2>
          {a.excerpt && <p>{a.excerpt}</p>}
        </article>
      ))}
    </main>
  );
}
app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getArticle } from "@/lib/overrank";

export const revalidate = 900;

const BASE = "https://yourdomain.com";

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const a = await getArticle(slug);
  if (!a) return {};
  const url = `${BASE}/blog/${a.slug}`;
  return {
    title: a.title,
    description: a.metaDescription ?? undefined,
    alternates: { canonical: url },
    openGraph: {
      type: "article",
      url,
      title: a.title,
      description: a.metaDescription ?? undefined,
      images: a.thumbnailUrl ? [a.thumbnailUrl] : undefined,
      publishedTime: a.publishedAt ?? undefined,
    },
  };
}

// JSON for a <script> tag, with "<" escaped so the HTML cannot break out.
function jsonLd(data: unknown) {
  return { __html: JSON.stringify(data).replace(/</g, "\\u003c") };
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params;
  const a = await getArticle(slug);
  if (!a) notFound();
  const url = `${BASE}/blog/${a.slug}`;

  const schema: object[] = [
    {
      "@context": "https://schema.org",
      "@type": "BlogPosting",
      headline: a.title,
      description: a.metaDescription ?? undefined,
      image: a.thumbnailUrl ?? undefined,
      datePublished: a.publishedAt ?? undefined,
      mainEntityOfPage: url,
    },
  ];
  if (a.faqs.length > 0) {
    schema.push({
      "@context": "https://schema.org",
      "@type": "FAQPage",
      mainEntity: a.faqs.map((f) => ({
        "@type": "Question",
        name: f.question,
        acceptedAnswer: { "@type": "Answer", text: f.answer },
      })),
    });
  }

  return (
    <main>
      <article>
        <h1>{a.title}</h1>
        {a.thumbnailUrl && <img src={a.thumbnailUrl} alt={a.title} width={1200} height={675} />}
        {/* Ready-to-render HTML. It has no H1, so the title above stays the only one. */}
        <div dangerouslySetInnerHTML={{ __html: a.content }} />
        {a.ctaButton?.enabled && (
          <p>
            <a href={a.ctaButton.url}>{a.ctaButton.label}</a>
          </p>
        )}
      </article>
      <script type="application/ld+json" dangerouslySetInnerHTML={jsonLd(schema)} />
    </main>
  );
}
  • On Next.js 14, params and searchParams are plain objects. Remove the Promise types; the awaits still work.
  • Want a related posts section? relatedArticles has 3 articles with a title, slug and image.

4. Add the articles to your sitemap

List every article address in your sitemap so Google finds new ones quickly. If you already have a sitemap, add the article addresses to it instead.

app/sitemap.ts
import type { MetadataRoute } from "next";
import { getArticles } from "@/lib/overrank";

const BASE = "https://yourdomain.com";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await getArticles();
  return [
    { url: `${BASE}/`, changeFrequency: "weekly", priority: 1 },
    { url: `${BASE}/blog`, changeFrequency: "daily", priority: 0.8 },
    ...articles.map((a) => ({
      url: `${BASE}/blog/${a.slug}`,
      lastModified: a.publishedAt ? new Date(a.publishedAt) : undefined,
      changeFrequency: "monthly" as const,
      priority: 0.7,
    })),
  ];
}

And point robots.txt at it. Skip this if your robots.txt already lists your sitemap.

app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: "https://yourdomain.com/sitemap.xml",
  };
}

5. Send old links to the new pages

Until you go live, links to the articles use the embed address yourdomain.com/insights?post=article-slug. Redirect those with a permanent (301) redirect so no link breaks.

app/insights/page.tsx
import { permanentRedirect } from "next/navigation";

type Props = { searchParams: Promise<{ post?: string }> };

// Old links from the 2-line embed: /insights?post=<slug> goes to /blog/<slug>.
export default async function OldBlogLinks({ searchParams }: Props) {
  const { post } = await searchParams;
  permanentRedirect(post ? `/blog/${encodeURIComponent(post)}` : "/blog");
}

This replaces any page that lives at /insights today.

6. Tell us you're live

Email hello@overrank.ai with the address of one live article, for example https://yourdomain.com/blog/article-slug. We check that it loads, then point every link inside the articles, the blog's sitemap and our emails at your new pages. From then on, each new article shows up on your site by itself.

Not on Next.js?

Any stack works the same way: make the two requests on your server, cache the answer for about 15 minutes, and render the fields in your templates. Nuxt, Astro, SvelteKit, Remix, Laravel, Django and Rails all follow the same steps. Here is a minimal PHP version of the article page:

article.php
<?php
// article.php: one overrank article as a normal page on yourdomain.com.
// Route /blog/<slug> to this file with the slug in $_GET['slug'] (a rewrite rule).
$site = 'yourdomain.com';
$slug = $_GET['slug'] ?? '';
$api = 'https://www.overrank.ai/api/articles/public?site=' . rawurlencode($site)
    . '&slug=' . rawurlencode($slug);

// Keep each article for 15 minutes so pages stay fast.
$cache = sys_get_temp_dir() . '/overrank-' . md5($api) . '.json';
if (is_file($cache) && time() - filemtime($cache) < 900) {
    $json = file_get_contents($cache);
} else {
    $json = @file_get_contents($api);
    if ($json !== false) {
        file_put_contents($cache, $json);
    }
}
$a = $json ? json_decode($json, true) : null;
if (!is_array($a) || empty($a['content'])) {
    http_response_code(404);
    exit('Article not found');
}
$e = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
$canonical = 'https://' . $site . '/blog/' . rawurlencode($a['slug']);
?><!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title><?= $e($a['title']) ?></title>
  <meta name="description" content="<?= $e($a['metaDescription'] ?? '') ?>">
  <link rel="canonical" href="<?= $e($canonical) ?>">
</head>
<body>
  <h1><?= $e($a['title']) ?></h1>
  <?= $a['content'] ?>
</body>
</html>

The list page is the same idea with the list request.

Before you send us the link

  • View the page source: the article text is in the HTML, not loaded later by JavaScript.
  • The page title is the article title, and the meta description is metaDescription.
  • One H1 per article page: the title.
  • The canonical tag points at the article's own address.
  • The article page answers 200 and has no noindex tag.
  • Every article is in your sitemap.
  • Images load and have alt text.
  • Old ?post= links redirect to the new pages.

Questions? Email hello@overrank.ai. The full API and webhook reference is at overrank.ai/docs.