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
loaderof yours already calls the Instagram API with a token that someone renews. Keep it. Library-mode apps built oncreateBrowserRouterwithoutreact-router.config.tsbelong 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 --jsonin the project root, then return the demo feed from the routeloadershown 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.
| Mode | Config | Data function | When it runs | Instagram token |
|---|---|---|---|---|
| Server rendering | ssr: true (default) | loader | Every request, on the server | Allowed, inside a .server.ts module |
| Prerender | prerender: ["/"] | loader | Once, during react-router build | Only in CI; posts freeze until the next build |
| SPA mode | ssr: false without prerender | clientLoader | In the browser | Never |
Sources: React Router: Pre-Rendering, checked 2026-09-18; React Router: Data Loading, checked 2026-09-18.
a
loaderis permitted on the root route only
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
divis 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:
- Only Business and Creator accounts get this API (Meta: Instagram Platform overview, checked 2026-09-18). Personal accounts lost API access when Basic Display shut down on 2024-12-04 (Meta developer blog, 2024-09-04, checked 2026-09-18).
- The long-lived token dies after 60 days (Meta: access_token reference, checked 2026-09-18). A refresh works only while the token is at least 24 hours old and not yet expired (Meta: refresh_access_token reference, checked 2026-09-18). Details: access tokens.
media_product_type, the field that separates reels from other videos, comes back only when requested by name (Meta: IG Media reference, checked 2026-09-18).
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
loaderwithssr: false. In SPA mode only the root route and prerendered paths may exportloader. Switch toclientLoaderor add the path toprerender. - 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. KeepIG_ACCESS_TOKENin the server environment of the host and read it only from a.server.tsfile. - The widget container stays empty after navigation. Something put another class in front of
electricblaze-id-.