blogsforseo

Connect: React Router / Remix

Connect a React site that renders on a server: React Router in framework mode (version 7 or newer), or Remix. Route loaders fetch from our API on the server on each request, so the key never reaches the browser.

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.

A plain React app won't work

A React app that only runs in the browser (Vite's React template, Create React App, or React Router in data or declarative mode with no server) can't show your posts to search engines and AI assistants. Its server sends an empty page, and the posts only appear once JavaScript runs. AI crawlers like GPTBot, ClaudeBot and PerplexityBot don't run JavaScript, so they'd see an empty blog. Fetching our API from the browser would also put your API key in every visitor's hands.

What to do instead:

  • Move to React Router framework mode. It keeps your React components and adds server rendering. React Router's docs have a guide for adding it to an existing Vite app.
  • Or move to Next.js and follow the Next.js guide.
  • Or serve the blog from another server. If your host can send /blog, /blog-sitemap.xml and /llms.txt to a small server app, any framework guide works for those routes, and the rest of your app stays as it is.
  • No developer? We can connect it for you. See Services.

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

Keep server rendering on: ssr: true (the default) in react-router.config.ts, and don't list the blog routes in prerender. Set BLOGSFORSEO_API_KEY in the server's environment. Never give it a VITE_ name: Vite copies those into the browser.

// app/lib/blogsforseo.server.ts (".server" means it's never bundled for the browser)
import { data } from "react-router";

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

/** 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> {
  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}` }, signal: AbortSignal.timeout(10_000) });
  if (response.status === 404) throw data("Not found", { status: 404 });
  if (!response.ok) throw new Error(`Blogs for SEO API answered ${response.status} for ${path}`);
  return response;
}

/** Post slugs only; anything else is a 404 without asking the API. */
export function postSlug(slug: string | undefined): string {
  if (!slug || !SLUG.test(slug)) throw data("Not found", { status: 404 });
  return slug;
}

/** ?page= as a whole number from 1; anything else is page 1. */
export function pageNumber(request: Request): number {
  const page = new URL(request.url).searchParams.get("page") ?? "";
  return /^[1-9]\d{0,5}$/.test(page) ? Number(page) : 1;
}
// app/routes.ts: add these next to your existing routes
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [
  // ...your existing routes
  route("blog", "routes/blog.tsx"),
  route("blog/:slug", "routes/blog-post.tsx"),
  route("blog-sitemap.xml", "routes/blog-sitemap.ts"),
  route("llms.txt", "routes/llms-txt.ts"),
] satisfies RouteConfig;
// app/routes/blog.tsx
import { bfs, pageNumber } from "../lib/blogsforseo.server";
import type { Route } from "./+types/blog";

export async function loader({ request }: Route.LoaderArgs) {
  return (await (await bfs("/html/posts", { page: pageNumber(request) })).json()) as { body: string };
}

export default function Blog({ loaderData }: Route.ComponentProps) {
  // Our HTML is already sanitised.
  return <div dangerouslySetInnerHTML={{ __html: loaderData.body }} />;
}
// app/routes/blog-post.tsx
import { bfs, postSlug } from "../lib/blogsforseo.server";
import type { Route } from "./+types/blog-post";

type HtmlPost = {
  body: string;
  meta: {
    title: string;
    description: string;
    canonical: string | null;
    openGraph: { title: string; description: string; url: string | null; image: { url: string } | null; publishedTime: string; modifiedTime: string };
    twitter: { card: string; title: string; description: string; image: string | null };
  };
};

export async function loader({ params }: Route.LoaderArgs) {
  return (await (await bfs("/html/posts/" + postSlug(params.slug))).json()) as HtmlPost;
}

// React Router builds <head> from values, so use `meta` (the same values as `head`).
export function meta({ loaderData }: Route.MetaArgs) {
  if (!loaderData) return [{ title: "Not found" }];
  const { title, description, canonical, openGraph: og, twitter } = loaderData.meta;
  const tags = [
    { title },
    { name: "description", content: description },
    canonical ? { tagName: "link" as const, rel: "canonical", href: canonical } : null,
    { property: "og:type", content: "article" },
    { property: "og:title", content: og.title },
    { property: "og:description", content: og.description },
    og.url ? { property: "og:url", content: og.url } : null,
    og.image ? { property: "og:image", content: og.image.url } : null,
    { property: "article:published_time", content: og.publishedTime },
    { property: "article:modified_time", content: og.modifiedTime },
    { name: "twitter:card", content: twitter.card },
    { name: "twitter:title", content: twitter.title },
    { name: "twitter:description", content: twitter.description },
    twitter.image ? { name: "twitter:image", content: twitter.image } : null,
  ];
  return tags.filter((tag) => tag !== null);
}

export default function BlogPost({ loaderData }: Route.ComponentProps) {
  // The whole article, structured data included. Already sanitised.
  return <div dangerouslySetInnerHTML={{ __html: loaderData.body }} />;
}
// app/routes/blog-sitemap.ts (app/routes/llms-txt.ts is the same with "/llms.txt" and text/plain; charset=utf-8)
// No default export: this is a resource route that sends the file as it is.
import { bfs } from "../lib/blogsforseo.server";

export async function loader() {
  const xml = await (await bfs("/sitemap", { format: "xml" })).text();
  return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}

Your root route's <head> needs <Meta /> and <Links /> (the starter template has both).

  • The page reads the loader's data, never our API. React Router adds the post body a second time in the page's hydration data; that's expected, and search engines read the rendered HTML.
  • Caching: loaders run on every request. If you add a headers export or a CDN rule for the blog routes, keep max-age and s-maxage at 300 seconds or less.
  • 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: react-router build, then react-router-serve ./build/server/index.js (or your host's adapter) with BLOGSFORSEO_API_KEY set.
  • Still on Remix v2? The same loaders work. Import from @remix-run/node and @remix-run/react, read data with useLoaderData(), throw new Response("Not found", { status: 404 }) instead of data(), call the meta argument data instead of loaderData, and name the route files blog._index.tsx, blog.$slug.tsx, blog-sitemap[.]xml.ts and llms[.]txt.ts.

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.