Code examples
- Python (Django)
- PHP (Laravel)
- Ruby (Rails)
- Astro
- Nuxt
- SvelteKit
- The advanced way in these languages
- Other languages
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_KEYis read on the server and sent asAuthorization: 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
/blogand/blog-sitemap.xmltoo. - 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>.txtcontaining just the key. - robots.txt: add the sitemap line from your dashboard (
Sitemap: https://<your domain>/blog-sitemap.xml) topublic/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>.txtcontaining just the key. - robots.txt: add the sitemap line from your dashboard (
Sitemap: https://<your domain>/blog-sitemap.xml) topublic/robots.txt. - Caching: Rails sends
Cache-Control: privateby default, which is right. If you addRails.cache.fetch, useexpires_in: 5.minutesor less, and don't addexpires_inor 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>.txtcontaining just the key. - robots.txt: add the sitemap line from your dashboard (
Sitemap: https://<your domain>/blog-sitemap.xml) topublic/robots.txt. - Run it:
astro build, thennode dist/server/entry.mjswithBLOGSFORSEO_API_KEYset 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>.txtcontaining just the key. - robots.txt: add the sitemap line from your dashboard (
Sitemap: https://<your domain>/blog-sitemap.xml) topublic/robots.txt. - Run it:
nuxt build, thenNUXT_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>.txtcontaining just the key. - robots.txt: add the sitemap line from your dashboard (
Sitemap: https://<your domain>/blog-sitemap.xml) tostatic/robots.txt. - Run it:
vite build, thenBLOGSFORSEO_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