blogsforseo

Code examples

The Content API is plain HTTPS and JSON, so any server language works. These examples connect a site the simple way in six common stacks: Django, Laravel, Rails, Astro, Nuxt and SvelteKit. Each one is the whole job: config, the blog page, the post page, /blog-sitemap.xml, /llms.txt, the IndexNow key file and the robots.txt line.

Every example works the same way:

  • 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}.

Python (Django)

Uses requests. Add the settings, then the helper, views and routes to an app called blog.

# settings.py
import os

BLOGSFORSEO_API_URL = os.environ.get("BLOGSFORSEO_API_URL", "https://blogsforseo.com").rstrip("/") + "/api/v1"
BLOGSFORSEO_API_KEY = os.environ["BLOGSFORSEO_API_KEY"]  # Fails at startup when it's missing.
BLOGSFORSEO_INDEXNOW_KEY = "<your IndexNow key>"  # Not secret.
# blog/blogsforseo.py
import requests
from django.conf import settings
from django.http import Http404


def fetch(path, params=None):
    """Asks the Content API on every request. A 404 from the API is this page's 404."""
    response = requests.get(
        settings.BLOGSFORSEO_API_URL + path,
        params=params,
        headers={"Authorization": "Bearer " + settings.BLOGSFORSEO_API_KEY},
        timeout=10,
    )
    if response.status_code == 404:
        raise Http404("Not found")
    response.raise_for_status()
    return response
# blog/views.py
from urllib.parse import quote

from django.conf import settings
from django.http import HttpResponse
from django.shortcuts import render

from .blogsforseo import fetch


def page_number(request):
    try:
        return max(1, int(request.GET.get("page", "1")))
    except ValueError:
        return 1


def blog_index(request):
    data = fetch("/html/posts", {"page": page_number(request)}).json()
    return render(request, "blog/index.html", data)


def blog_post(request, slug):
    data = fetch("/html/posts/" + quote(slug)).json()
    return render(request, "blog/post.html", data)


def blog_sitemap(request):
    xml = fetch("/sitemap", {"format": "xml"}).content
    return HttpResponse(xml, content_type="application/xml")


def llms_txt(request):
    return HttpResponse(fetch("/llms.txt").text, content_type="text/plain; charset=utf-8")


def indexnow_key(request):
    return HttpResponse(settings.BLOGSFORSEO_INDEXNOW_KEY, content_type="text/plain")
# blog/urls.py (include it from your root urls.py with path("", include("blog.urls")))
from django.conf import settings
from django.urls import path

from . import views

urlpatterns = [
    path("blog", views.blog_index),
    path("blog/<slug:slug>", views.blog_post),
    path("blog-sitemap.xml", views.blog_sitemap),
    path("llms.txt", views.llms_txt),
    path(settings.BLOGSFORSEO_INDEXNOW_KEY + ".txt", views.indexnow_key),
]

Templates, in your own base layout. head and body are already escaped by us, so mark them safe:

<!-- blog/templates/blog/post.html; blog/index.html is the same without the head block -->
{% extends "base.html" %}
{% block head %}{{ head|safe }}{% endblock %}
{% block content %}{{ body|safe }}{% endblock %}

Your base.html needs a {% block head %}{% endblock %} inside <head>; leave out your own <title> on post pages. Then add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to your robots.txt, wherever you serve it.

Caching: these views fetch on every request. Don't wrap them in cache_page for longer than 300 seconds, and if you use Django's whole-site cache middleware, keep CACHE_MIDDLEWARE_SECONDS at 300 or less.

PHP (Laravel)

Uses Laravel's HTTP client (Laravel 9 or newer). Read the key through config(), not env(): after php artisan config:cache, env() returns null.

// config/services.php: add this entry to the returned array
'blogsforseo' => [
    'url' => rtrim(env('BLOGSFORSEO_API_URL', 'https://blogsforseo.com'), '/').'/api/v1',
    'key' => env('BLOGSFORSEO_API_KEY'),
],
<?php
// app/Support/BlogsForSeo.php

namespace App\Support;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class BlogsForSeo
{
    /** Asks the Content API on every request. A 404 from the API is this page's 404. */
    public static function get(string $path, array $query = []): Response
    {
        $response = Http::withToken(config('services.blogsforseo.key'))
            ->timeout(10)
            ->get(config('services.blogsforseo.url').$path, $query);

        abort_if($response->notFound(), 404);

        return $response->throw();
    }
}
// routes/web.php: add these routes, and add these two use lines to the top of the file
// if they're not there yet (Laravel's web.php already imports Route).
use App\Support\BlogsForSeo;
use Illuminate\Http\Request;

Route::get('/blog', function (Request $request) {
    $page = max(1, $request->integer('page', 1));

    return view('blog.index', BlogsForSeo::get('/html/posts', ['page' => $page])->json());
});

Route::get('/blog/{slug}', function (string $slug) {
    return view('blog.post', BlogsForSeo::get('/html/posts/'.rawurlencode($slug))->json());
})->where('slug', '[a-z0-9-]+');

Route::get('/blog-sitemap.xml', function () {
    return response(BlogsForSeo::get('/sitemap', ['format' => 'xml'])->body())
        ->header('Content-Type', 'application/xml');
});

Route::get('/llms.txt', function () {
    return response(BlogsForSeo::get('/llms.txt')->body())
        ->header('Content-Type', 'text/plain; charset=UTF-8');
});

Views, in your own layout. head and body are already escaped by us, so print them raw:

{{-- resources/views/blog/post.blade.php; blog/index.blade.php is the same without the head section --}}
@extends('layouts.app')

@section('head')
    {!! $head !!}
@endsection

@section('content')
    {!! $body !!}
@endsection

Your layout needs @yield('head') inside <head>; leave out your own <title> on post pages.

  • IndexNow key file: save public/<your IndexNow key>.txt containing just the key.
  • robots.txt: add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to public/robots.txt.
  • Caching: these routes fetch on every request. If you wrap a call in Cache::remember, use 300 seconds or less, and don't put the blog routes behind a response cache that keeps pages longer.

Ruby (Rails)

Uses Ruby's built-in Net::HTTP, so there's nothing to install.

# app/services/blogs_for_seo.rb
require "net/http"
require "json"

module BlogsForSeo
  API_URL = ENV.fetch("BLOGSFORSEO_API_URL", "https://blogsforseo.com").chomp("/") + "/api/v1"

  # Asks the Content API on every request. A 404 from the API is this page's 404.
  def self.get(path, query = {})
    uri = URI(API_URL + path)
    uri.query = URI.encode_www_form(query) unless query.empty?
    request = Net::HTTP::Get.new(uri)
    request["Authorization"] = "Bearer " + ENV.fetch("BLOGSFORSEO_API_KEY")
    response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 10) do |http|
      http.request(request)
    end
    raise ActionController::RoutingError, "Not found" if response.is_a?(Net::HTTPNotFound)

    response.value # Raises unless the API answered 2xx.
    response.body
  end
end
# app/controllers/blog_controller.rb
class BlogController < ApplicationController
  def index
    page = [params[:page].to_i, 1].max
    @blog = JSON.parse(BlogsForSeo.get("/html/posts", page: page))
  end

  def show
    @blog = JSON.parse(BlogsForSeo.get("/html/posts/" + ERB::Util.url_encode(params[:slug])))
  end

  def sitemap
    render plain: BlogsForSeo.get("/sitemap", format: "xml"), content_type: "application/xml"
  end

  def llms
    render plain: BlogsForSeo.get("/llms.txt")
  end
end
# config/routes.rb, inside Rails.application.routes.draw
get "blog", to: "blog#index"
get "blog/:slug", to: "blog#show", constraints: { slug: /[a-z0-9-]+/ }
get "blog-sitemap.xml", to: "blog#sitemap", format: false
get "llms.txt", to: "blog#llms", format: false

Views, in your own layout. head and body are already escaped by us, so print them raw:

<%# app/views/blog/show.html.erb %>
<% content_for :head, raw(@blog["head"]) %>
<%= raw @blog["body"] %>
<%# app/views/blog/index.html.erb %>
<%= raw @blog["body"] %>

Your layout needs <%= yield :head %> inside <head>; leave out your own <title> on post pages.

  • IndexNow key file: save public/<your IndexNow key>.txt containing just the key.
  • robots.txt: add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to public/robots.txt.
  • Caching: Rails sends Cache-Control: private by default, which is right. If you add Rails.cache.fetch, use expires_in: 5.minutes or less, and don't add expires_in or page caching to these actions for longer.

Astro

Astro 7 with the Node adapter (any server adapter works the same way). The key is an Astro secret, read when the server runs, never bundled.

// astro.config.mjs
import node from "@astrojs/node";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  output: "server", // Every page renders on each request, so new posts appear by themselves.
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      BLOGSFORSEO_API_KEY: envField.string({ context: "server", access: "secret" }),
      BLOGSFORSEO_API_URL: envField.string({ context: "server", access: "public", default: "https://blogsforseo.com" }),
    },
  },
});
// src/lib/blogsforseo.ts
import { BLOGSFORSEO_API_URL, getSecret } from "astro:env/server";

const API = BLOGSFORSEO_API_URL.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 (a 500 page). */
export async function bfs(path: string, query: Record<string, string | number> = {}): Promise<Response | null> {
  const key = getSecret("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}` }, 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(url: URL): number {
  const page = url.searchParams.get("page") ?? "";
  return /^[1-9]\d{0,5}$/.test(page) ? Number(page) : 1;
}
---
// src/pages/blog/index.astro
import Layout from "../../layouts/Layout.astro";
import { bfs, pageNumber } from "../../lib/blogsforseo";

const response = await bfs("/html/posts", { page: pageNumber(Astro.url) });
if (!response) return new Response("Not found", { status: 404 });
const { body } = await response.json();
---
<Layout title="Blog">
  <Fragment set:html={body} />
</Layout>
---
// src/pages/blog/[slug].astro: unknown slug shapes are a 404 without asking the API.
import Layout from "../../layouts/Layout.astro";
import { bfs, SLUG } from "../../lib/blogsforseo";

const slug = Astro.params.slug ?? "";
const response = SLUG.test(slug) ? await bfs("/html/posts/" + slug) : null;
if (!response) return new Response("Not found", { status: 404 });
const { head, body } = await response.json();
---
<Layout>
  <Fragment slot="head" set:html={head} />
  <Fragment set:html={body} />
</Layout>

Your layout needs <slot name="head" /> inside <head>, and no <title> of its own on post pages.

// src/pages/blog-sitemap.xml.ts (src/pages/llms.txt.ts is the same with "/llms.txt" and text/plain)
import type { APIRoute } from "astro";
import { bfs } from "../lib/blogsforseo";

export const GET: APIRoute = async () => {
  const response = await bfs("/sitemap", { format: "xml" });
  return new Response(response ? await response.text() : "", { status: response ? 200 : 404, headers: { "Content-Type": "application/xml" } });
};
  • IndexNow key file: save public/<your IndexNow key>.txt containing just the key.
  • robots.txt: add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to public/robots.txt.
  • Run it: astro build, then node dist/server/entry.mjs with BLOGSFORSEO_API_KEY set in the server's environment.

Nuxt

Nuxt 4. The key lives in server-only runtime config and is used only in server routes, so it never reaches the browser.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only, and empty here on purpose: set NUXT_BLOGSFORSEO_API_KEY (and optionally
    // NUXT_BLOGSFORSEO_API_URL) when the server starts. Never write process.env.… here: Nuxt
    // would copy the key into .output at build time.
    blogsforseoApiKey: "",
    blogsforseoApiUrl: "https://blogsforseo.com",
  },
});
// server/utils/blogsforseo.ts
/** Asks the Content API on every request. A 404 from the API is a 404 here; any other error is a 500. */
export async function bfs(path: string, query: Record<string, string | number> = {}) {
  const { blogsforseoApiKey: key, blogsforseoApiUrl } = useRuntimeConfig();
  if (!key) throw createError({ statusCode: 500, statusMessage: "BLOGSFORSEO_API_KEY is not set" });
  const url = new URL(blogsforseoApiUrl.replace(/\/$/, "") + "/api/v1" + path);
  for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
  const response = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
  if (response.status === 404) throw createError({ statusCode: 404, statusMessage: "Not found" });
  if (!response.ok) throw createError({ statusCode: 500, statusMessage: `Blogs for SEO API answered ${response.status}` });
  return response;
}

/** ?page= as a whole number from 1; anything else is page 1. */
export function pageNumber(raw: unknown): number {
  return typeof raw === "string" && /^[1-9]\d{0,5}$/.test(raw) ? Number(raw) : 1;
}
// server/api/blog/posts.get.ts
export default defineEventHandler(async (event) => (await bfs("/html/posts", { page: pageNumber(getQuery(event).page) })).json());
// server/api/blog/[slug].get.ts
export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, "slug") ?? "";
  if (!/^[a-z0-9-]+$/.test(slug)) throw createError({ statusCode: 404, statusMessage: "Not found" });
  return (await bfs("/html/posts/" + slug)).json();
});
// server/routes/blog-sitemap.xml.ts (server/routes/llms.txt.ts is the same with "/llms.txt" and text/plain)
export default defineEventHandler(async (event) => {
  setHeader(event, "Content-Type", "application/xml");
  return (await bfs("/sitemap", { format: "xml" })).text();
});
<!-- app/pages/blog/[slug].vue (blog/index.vue fetches /api/blog/posts with ?page= and shows body the same way) -->
<script setup lang="ts">
const slug = String(useRoute().params.slug);
if (!/^[a-z0-9-]+$/.test(slug)) throw createError({ statusCode: 404, fatal: true });
const { data, error } = await useFetch(`/api/blog/${slug}`);
if (error.value) throw createError({ statusCode: error.value.statusCode ?? 500, fatal: true });

// Nuxt builds <head> from data, so use `meta` (the same values as `head`).
const meta = data.value!.meta;
useSeoMeta({
  title: meta.title,
  description: meta.description,
  ogType: "article",
  ogTitle: meta.openGraph.title,
  ogDescription: meta.openGraph.description,
  ogUrl: meta.openGraph.url ?? undefined,
  ogImage: meta.openGraph.image?.url,
  articlePublishedTime: meta.openGraph.publishedTime,
  articleModifiedTime: meta.openGraph.modifiedTime,
  twitterCard: meta.twitter.card,
  twitterTitle: meta.twitter.title,
  twitterDescription: meta.twitter.description,
  twitterImage: meta.twitter.image ?? undefined,
});
useHead({ link: meta.canonical ? [{ rel: "canonical", href: meta.canonical }] : [] });
</script>

<template>
  <div v-html="data?.body" />
</template>
  • The page fetches your own server route (/api/blog/...), never our API: the key stays on the server. Nuxt adds the post body a second time in the page's hydration data; that's expected, and search engines read the rendered HTML.
  • IndexNow key file: save public/<your IndexNow key>.txt containing just the key.
  • robots.txt: add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to public/robots.txt.
  • Run it: nuxt build, then NUXT_BLOGSFORSEO_API_KEY=… node .output/server/index.mjs.

SvelteKit

SvelteKit 3 with the Node adapter. The key is a private environment variable read when the server starts, never baked into the build.

// vite.config.js
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [sveltekit({ adapter: adapter() })] });
// src/env.ts
import { defineEnvVars } from "@sveltejs/kit/env";

// Server-only, read when the app starts. Optional here because SvelteKit also reads these during
// `vite build`; the API helper below fails clearly if the key is missing at runtime.
export const variables = defineEnvVars({
  BLOGSFORSEO_API_KEY: { schema: (value) => value ?? "" },
  BLOGSFORSEO_API_URL: { schema: (value) => value || "https://blogsforseo.com" },
});
// src/params.ts: post slugs only; anything else is a 404 without asking the API.
import { defineParams } from "@sveltejs/kit/params";

export const params = defineParams({ slug: (param) => (/^[a-z0-9-]+$/.test(param) ? param : undefined) });
// src/lib/server/blogsforseo.ts (imported as #lib/server/blogsforseo: add "imports": { "#lib/*": "./src/lib/*" } to package.json)
import { BLOGSFORSEO_API_KEY, BLOGSFORSEO_API_URL } from "$app/env/private";
import { error } from "@sveltejs/kit";

const API = BLOGSFORSEO_API_URL.replace(/\/$/, "") + "/api/v1";

/** Asks the Content API on every request. A 404 from the API is this page's 404; any other error is a 500. */
export async function bfs(path: string, query: Record<string, string | number> = {}): Promise<Response> {
  if (!BLOGSFORSEO_API_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 ${BLOGSFORSEO_API_KEY}` }, signal: AbortSignal.timeout(10_000) });
  if (response.status === 404) error(404, "Not found");
  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(url: URL): number {
  const page = url.searchParams.get("page") ?? "";
  return /^[1-9]\d{0,5}$/.test(page) ? Number(page) : 1;
}
// src/routes/blog/+page.server.ts
import { bfs, pageNumber } from "#lib/server/blogsforseo";

export const load = async ({ url }) => (await bfs("/html/posts", { page: pageNumber(url) })).json() as Promise<{ body: string }>;
// src/routes/blog/[slug=slug]/+page.server.ts
import { bfs } from "#lib/server/blogsforseo";

export const load = async ({ params }) => (await bfs("/html/posts/" + params.slug)).json() as Promise<{ head: string; body: string }>;
<!-- src/routes/blog/[slug=slug]/+page.svelte (blog/+page.svelte is the same with only {@html data.body}) -->
<script lang="ts">
  let { data } = $props();
</script>

<svelte:head>{@html data.head}</svelte:head>
{@html data.body}
// src/routes/blog-sitemap.xml/+server.ts (src/routes/llms.txt/+server.ts is the same with "/llms.txt" and text/plain)
import { bfs } from "#lib/server/blogsforseo";

export const GET = async () => new Response(await (await bfs("/sitemap", { format: "xml" })).text(), { headers: { "Content-Type": "application/xml" } });
  • IndexNow key file: save static/<your IndexNow key>.txt containing just the key.
  • robots.txt: add the sitemap line from your dashboard (Sitemap: https://<your domain>/blog-sitemap.xml) to static/robots.txt.
  • Run it: vite build, then BLOGSFORSEO_API_KEY=… node build.

The advanced way in these languages

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.dumps(post["jsonLd"]).replace("<", r"\u003c")
json_encode($post['jsonLd'], JSON_HEX_TAG | JSON_UNESCAPED_SLASHES)
JSON.generate(post["jsonLd"]).gsub("<", '\u003c')

Other languages

Anything that can make an HTTPS request works: send the header, render on the server, pass our 404 through, and follow the simple or advanced guide. To try the API from a terminal:

curl -H "Authorization: Bearer $BLOGSFORSEO_API_KEY" https://blogsforseo.com/api/v1/html/posts