blogsforseo

Connect: Next.js

Connect a Next.js site that uses the App Router (Next.js 15 or newer). The blog pages are Server Components that fetch from our API on each request, so the key stays on your server and new posts show up without a rebuild.

This is the simple way: we send finished HTML with the SEO done, and your site shows it in its own design. Using a coding agent? Copy the prompt from your dashboard's Integration page instead: it has your values filled in and the same steps for this framework.

How it works

  • The key comes from the environment. BLOGSFORSEO_API_KEY is read on the server and sent as Authorization: Bearer <key>. It's never in the code, the templates or the browser.
  • Every visit asks our API. The pages are rendered on each request, never saved at build time, so new posts, edits and removals show up on their own. The one rule for caching: nothing may keep our API's answer for more than 5 minutes. If you add a cache, keep it at 300 seconds or less, and check your CDN rules on /blog and /blog-sitemap.xml too.
  • A 404 from us is a 404 from you. An unknown or removed post must answer a real 404, not an empty page. Any other error fails the request, so your framework shows its normal error page.

Match the routes to your post URL pattern in the dashboard: these use /blog and /blog/{slug}. In this guide, {API} means https://blogsforseo.com/api/v1.

Set it up

Put the key in .env.local as BLOGSFORSEO_API_KEY, and in your host's environment settings for production. Never give it a NEXT_PUBLIC_ name: Next.js copies those into the browser.

// lib/blogsforseo.ts
import "server-only"; // The build fails if a client component imports this file.
import { connection } from "next/server";

const API = (process.env.BLOGSFORSEO_API_URL ?? "https://blogsforseo.com").replace(/\/$/, "") + "/api/v1";
export const SLUG = /^[a-z0-9-]+$/;

/** Asks the Content API on every request. Returns null on a 404; any other error throws (your error page). */
export async function bfs(path: string, query: Record<string, string | number> = {}): Promise<Response | null> {
  await connection(); // Render on each request, never at build time.
  const key = process.env.BLOGSFORSEO_API_KEY;
  if (!key) throw new Error("BLOGSFORSEO_API_KEY is not set");
  const url = new URL(API + path);
  for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${key}` },
    cache: "no-store",
    signal: AbortSignal.timeout(10_000),
  });
  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`Blogs for SEO API answered ${response.status} for ${path}`);
  return response;
}

/** ?page= as a whole number from 1; anything else is page 1. */
export function pageNumber(raw: string | string[] | undefined): number {
  return typeof raw === "string" && /^[1-9]\d{0,5}$/.test(raw) ? Number(raw) : 1;
}
// app/blog/page.tsx
import { notFound } from "next/navigation";
import { bfs, pageNumber } from "@/lib/blogsforseo";

export default async function Blog({ searchParams }: { searchParams: Promise<{ page?: string | string[] }> }) {
  const response = await bfs("/html/posts", { page: pageNumber((await searchParams).page) });
  if (!response) notFound();
  const { body }: { body: string } = await response.json();
  // Our HTML is already sanitised.
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cache } from "react";
import { bfs, SLUG } from "@/lib/blogsforseo";

type HtmlPost = {
  body: string;
  meta: {
    title: string;
    description: string;
    canonical: string | null;
    openGraph: {
      title: string;
      description: string;
      url: string | null;
      image: { url: string; width: number; height: number; alt: string } | null;
      publishedTime: string;
      modifiedTime: string;
    };
    twitter: { card: "summary_large_image" | "summary"; title: string; description: string; image: string | null };
  };
};
type Props = { params: Promise<{ slug: string }> };

/** One API request per visit, shared by generateMetadata and the page. Other slug shapes are a 404 without asking. */
const getPost = cache(async (slug: string): Promise<HtmlPost> => {
  const response = SLUG.test(slug) ? await bfs("/html/posts/" + slug) : null;
  if (!response) notFound();
  return response.json();
});

// Next.js builds <head> from values, so use `meta` (the same values as `head`).
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { meta } = await getPost((await params).slug);
  return {
    title: { absolute: meta.title },
    description: meta.description,
    alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
    openGraph: {
      type: "article",
      title: meta.openGraph.title,
      description: meta.openGraph.description,
      url: meta.openGraph.url ?? undefined,
      images: meta.openGraph.image ? [meta.openGraph.image] : undefined,
      publishedTime: meta.openGraph.publishedTime,
      modifiedTime: meta.openGraph.modifiedTime,
    },
    twitter: {
      card: meta.twitter.card,
      title: meta.twitter.title,
      description: meta.twitter.description,
      images: meta.twitter.image ? [meta.twitter.image] : undefined,
    },
  };
}

export default async function BlogPost({ params }: Props) {
  const { body } = await getPost((await params).slug);
  // The whole article, structured data included. Already sanitised.
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}
// app/blog-sitemap.xml/route.ts (app/llms.txt/route.ts is the same with "/llms.txt" and text/plain; charset=utf-8)
import { bfs } from "@/lib/blogsforseo";

export async function GET() {
  const response = await bfs("/sitemap", { format: "xml" });
  if (!response) return new Response("Not found", { status: 404 });
  return new Response(await response.text(), { headers: { "Content-Type": "application/xml" } });
}
  • Caching: cache: "no-store" and connection() make every blog route render per request. Don't add 'use cache', unstable_cache or export const revalidate longer than 300 seconds to them. After next build, the build output must not mark /blog, /blog/[slug] or /blog-sitemap.xml as static.
  • Real 404s: don't add a loading.tsx to the blog routes. A page that starts streaming can't change its status, so an unknown post would answer 200. With cacheComponents: true, add export const instant = false; to both blog pages so they can render in full before sending.
  • Meta tags in <head> for AI crawlers: Next.js streams page metadata to most visitors, and puts it in <head> only for the bots on its htmlLimitedBots list. Add the AI crawlers in next.config.ts. Setting the option replaces Next.js's own list, so keep its bots in it too:
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  htmlLimitedBots:
    /[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight|Googlebot|GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-User|Claude-SearchBot|PerplexityBot|Perplexity-User|CCBot|Amazonbot|Bytespider/i,
};

export default nextConfig;
  • IndexNow key file: save public/<your IndexNow key>.txt containing just the key.
  • robots.txt: with public/robots.txt, add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml). With app/robots.ts, add that address to its sitemap list.
  • Static export: output: 'export' can't fetch per request. Remove it, and host on something that runs Next.js (Vercel, Netlify, a Node server, or similar).

The advanced way

Fetch /posts and /posts/{slug} instead of the /html endpoints, and build the page from the fields in the Content API reference, following the advanced guide. bodyHtml is already sanitised: print it raw. When you put jsonLd in a <script type="application/ld+json">, escape <:

JSON.stringify(post.jsonLd).replace(/</g, "\\u003c")

Check it

Run these against your site (local or live). -A GPTBot asks the way an AI crawler does, without JavaScript:

curl -A GPTBot https://<your domain>/blog                 # lists posts, or "No posts yet"
curl -A GPTBot https://<your domain>/blog/<a post slug>   # <h1>, article text, canonical link, application/ld+json
curl -i https://<your domain>/blog/not-a-real-post        # HTTP 404
curl https://<your domain>/blog-sitemap.xml               # the same posts as GET {API}/sitemap
curl https://<your domain>/llms.txt                       # plain text
curl https://<your domain>/<your IndexNow key>.txt        # just the key
curl https://<your domain>/robots.txt                     # has the Sitemap: line

Then check it stays fresh: the number of posts on /blog matches total from the API. Finish with the simple guide's dashboard steps: check the post address, run the health check and connect Search Console.