Instagram feed in React Router framework mode and Remix: loader, clientLoader, prerender

A React Router app can render on the server, at build time or only in the browser. Which of the three your config picks decides where Instagram data may load and where a token may live.

At a glance

Status, checked 2026-09-18: the ElectricBlaze widget shows real posts in React Router v7 and v8 apps today, in every render mode. npx electricblaze add instagram-feed writes demo JSON and types for a route loader; the API for real posts ships in 0.2.

Use it when
the app runs React Router v7 or v8 in framework mode (it has a react-router.config.ts) or is still on Remix v2, and one route should list recent Instagram posts.
Do not use it when
a server loader of yours already calls the Instagram API with a token that someone renews. Keep it. Library-mode apps built on createBrowserRouter without react-router.config.ts belong on the React and Vite page.
What the human does
for real posts, the account owner connects Instagram to an ElectricBlaze widget at a.electricblaze.com and shares the widget id. For demo data, nothing at all.
Next step
run npx [email protected] add instagram-feed --json in the project root, then return the demo feed from the route loader shown below.

Render mode decides where data loads

Framework mode has three ways to produce HTML, all set in react-router.config.ts. Each mode permits a different data function. That function is the only place a secret could hide.

React Router render modes and the data functions each allows
ModeConfigData functionWhen it runsInstagram token
Server renderingssr: true (default)loaderEvery request, on the serverAllowed, inside a .server.ts module
Prerenderprerender: ["/"]loaderOnce, during react-router buildOnly in CI; posts freeze until the next build
SPA modessr: false without prerenderclientLoaderIn the browserNever

Sources: React Router: Pre-Rendering, checked 2026-09-18; React Router: Data Loading, checked 2026-09-18.

a loader is permitted on the root route only

React Router docs on ssr: false without prerender, Pre-Rendering, checked 2026-09-18

The widget fetches in the browser, so all three modes can use it. The CLI demo feed is a local file that any data function can return. Hand-written Graph API code belongs in a server loader and nowhere else.

React Router v8 came out on 2026-06-17. Everything this page uses behaves the same in v7 and v8: loader, clientLoader, prerender, route types from ./+types/... and .server modules. The same release declared Remix v2 end of life (Remix blog, checked 2026-09-18). We built the examples below with react-router 8.4.0 and Vite 8.3 on 2026-09-18: react-router typegen, tsc and react-router build with a prerendered / all pass.

The widget in a route component

Inject widget.js from an effect instead of the Layout in root.tsx. Effects run after hydration, so the loader never adds posts to server-rendered markup that React is still matching against its own tree.

app/components/InstagramWidget.tsx

import { useEffect } from "react";

const LOADER = "https://s.electricblaze.com/widget.js";

export function InstagramWidget({ widgetId }: { widgetId: string }) {
  useEffect(() => {
    // Runs after hydration, so widget.js never edits markup React is still matching.
    if (document.querySelector(`script[src="${LOADER}"]`)) return;
    const script = document.createElement("script");
    script.src = LOADER;
    script.async = true;
    document.body.append(script);
  }, []);

  // The widget id must be the first class.
  return <div className={`electricblaze-id-${widgetId}`} />;
}

Once loaded, the script stays on the page and watches the DOM. A route reached through <Link> that renders another InstagramWidget mounts without a second script tag, and a duplicate tag would be ignored anyway.

  • Class order matters. className="electricblaze-id-abc123 feed" mounts. className="feed electricblaze-id-abc123" stays an empty box, because the loader takes the id from the first class.
  • Prerendered pages keep working. The div is in the static HTML and the posts arrive in the browser, so a page built once still shows current posts.
  • Styling lives in the dashboard. Columns, gaps, captions and the slider are widget settings. Free covers 5 widgets and 1000 views per month with data refreshed every 24 hours; see pricing for Start.

Demo data from the CLI in a loader

The default template lists react-router in package.json, so add picks the data kit. That template has no src/ folder. The two files therefore land in lib/eb/ at the project root, next to app/, not inside it.

npx [email protected] add instagram-feed --json

Output in a project from the default React Router template (dir, next and the second note removed)

{
  "ok": true,
  "component": "instagram-feed",
  "source": "instagram",
  "framework": "data",
  "files": [
    { "path": "lib/eb/feed.d.ts", "status": "written" },
    { "path": "lib/eb/demo/instagram.json", "status": "written" }
  ],
  "demo": true,
  "notes": [
    "react-router detected: no component template for it yet, so only the feed JSON and types are written. Build the component against them (see the skill), or pass --framework=html for the plain HTML kit."
  ]
}

Return the feed from the route loader. The component then receives typed loaderData, whether the route renders per request or once at build time.

app/routes/home.tsx

import type { Route } from "./+types/home";
import type { Feed } from "../../lib/eb/feed";
import demo from "../../lib/eb/demo/instagram.json";
import { InstagramWidget } from "~/components/InstagramWidget";

export async function loader() {
  const feed = demo as Feed;
  return { feed: { ...feed, posts: feed.posts.slice(0, 8) } };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { feed } = loaderData;
  return (
    <main>
      <InstagramWidget widgetId="WIDGET_ID" />
      {feed.demo && <p>demo feed</p>}
      <ul className="ig-grid">
        {feed.posts.map((post) => (
          <li key={post.id}>
            <a href={post.url} target="_blank" rel="noopener noreferrer">
              <img src={post.thumbnailUrl} alt={post.media[0]?.alt ?? post.text} loading="lazy" />
              {post.format === "reel" && <span>Reel</span>}
            </a>
          </li>
        ))}
      </ul>
    </main>
  );
}

react-router.config.ts

import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  // Render "/" to static HTML at build time; its loader runs once, during the build.
  prerender: ["/"],
} satisfies Config;

The template maps ~/ to app/, so the JSON outside app/ needs a relative import; its tsconfig.json already enables resolveJsonModule. In SPA mode, rename loader to clientLoader and export a HydrateFallback for the first paint. The JSON is bundled either way.

Posts follow schema v1. Show a "demo feed" label while feed.demo is true; only a connected account turns it off, and that arrives with the 0.2 API.

Your own Graph API token in a .server.ts module

Calling Instagram yourself is a server-rendering job. It brings the Meta paperwork along:

app/lib/instagram.server.ts

// .server.ts: the build fails if client code imports this file, so the token stays on the server.
const FIELDS = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp";

export type IgMedia = {
  id: string;
  caption?: string;
  media_type: "IMAGE" | "VIDEO" | "CAROUSEL_ALBUM";
  media_product_type?: "FEED" | "REELS" | "STORY" | "AD";
  media_url?: string;
  thumbnail_url?: string;
  permalink: string;
  timestamp: string;
};

export async function fetchInstagramMedia(limit = 12): Promise<IgMedia[]> {
  const token = process.env.IG_ACCESS_TOKEN;
  if (!token) throw new Error("IG_ACCESS_TOKEN is not set");
  const url = new URL("https://graph.instagram.com/me/media");
  url.searchParams.set("fields", FIELDS);
  url.searchParams.set("limit", String(limit));
  url.searchParams.set("access_token", token);
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Instagram API ${res.status}: ${await res.text()}`);
  return (await res.json()).data;
}

app/routes/instagram.tsx

import type { Route } from "./+types/instagram";
import { fetchInstagramMedia } from "~/lib/instagram.server";

export async function loader() {
  return { media: await fetchInstagramMedia(12) };
}

// Short cache: media_url links from Instagram expire.
export function headers() {
  return { "Cache-Control": "public, max-age=600" };
}

export default function Instagram({ loaderData }: Route.ComponentProps) {
  return (
    <ul className="ig-grid">
      {loaderData.media.map((m) => (
        <li key={m.id}>
          <a href={m.permalink} target="_blank" rel="noopener noreferrer">
            <img src={m.thumbnail_url ?? m.media_url} alt={m.caption ?? ""} loading="lazy" />
          </a>
        </li>
      ))}
    </ul>
  );
}

The .server suffix is the guard. React Router refuses to build when a .server module ends up in the client module graph, so a stray import cannot ship the token. Never read the token inside clientLoader: that function is part of the browser bundle.

Leave this route out of prerender. A prerendered page freezes the media_url values from build day, and Instagram CDN links stop working after a while. If the page must be static, download the images during the build, as the static site workflow does. The headers export only works with a runtime server; the docs forbid it under ssr: false.

Remix v2

The CLI detects @remix-run/react and writes the same data kit. Remix v2 no longer gets security updates since the React Router v8 release, and the upgrade path runs through React Router v7. Until then, the widget component above works unchanged, and the demo loader reads like this:

app/routes/_index.tsx

import { useLoaderData } from "@remix-run/react";
import type { Feed } from "../../lib/eb/feed";
import demo from "../../lib/eb/demo/instagram.json";

export async function loader() {
  return { feed: demo as Feed };
}

export default function Index() {
  const { feed } = useLoaderData<typeof loader>();
  return <p>{feed.posts.length} posts from @{feed.origin.name}</p>;
}

Failure modes

  • Build error about a route loader with ssr: false. In SPA mode only the root route and prerendered paths may export loader. Switch to clientLoader or add the path to prerender.
  • Broken thumbnails some time after deploy. A prerendered route kept Instagram links from build day. Rebuild on a schedule, download the files at build time, or use the widget.
  • The token shows up in the browser bundle. It was named with a VITE_ prefix or read in client code. Keep IG_ACCESS_TOKEN in the server environment of the host and read it only from a .server.ts file.
  • The widget container stays empty after navigation. Something put another class in front of electricblaze-id-.

FAQ

Should an Instagram feed use loader or clientLoader in React Router?
loader when the route renders on the server or at build time; clientLoader in SPA mode. An access token only ever belongs in a server loader.
Can I prerender a React Router page with an Instagram feed?

Yes, with the widget, which loads posts in the browser, and with the demo feed. With your own API code the image links in the prerendered HTML expire, so download the images during the build.

Does the CLI generate a React Router component?

No. Version 0.1.2 writes lib/eb/feed.d.ts and lib/eb/demo/instagram.json; the route module on this page renders them.

Does this code change in React Router v8?

No. Loaders, clientLoader, prerender and .server modules work the same in v7 and v8. v8 needs Node 22.22 or newer and React 19.2.7 or newer (upgrade guide, checked 2026-09-18).

Does it work in Remix v2?

Yes: the same loader with useLoaderData from @remix-run/react. Plan the move to React Router, because Remix v2 is past end of life.