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

**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.

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

Canonical: https://electricblaze.com/developer/instagram-feed/react-router.html

## At a glance

- **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](https://electricblaze.com/developer/instagram-feed/react.html) page.
- **What the human does:** for real posts, the account owner connects Instagram to an ElectricBlaze widget at [a.electricblaze.com](https://a.electricblaze.com/) and shares the widget id. For demo data, nothing at all.
- **Next step:** run `npx electricblaze@0.1.2 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.

| 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](https://reactrouter.com/how-to/pre-rendering), checked 2026-09-18; [React Router: Data Loading](https://reactrouter.com/start/framework/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](https://reactrouter.com/how-to/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](https://remix.run/blog/react-router-v8), 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:

```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](https://electricblaze.com/pricing.html) 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.

```sh
npx electricblaze@0.1.2 add instagram-feed --json
```

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

```json
{
  "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:

```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:

```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](https://electricblaze.com/developer/instagram-feed/schema.html). 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](https://developers.facebook.com/docs/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](https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/), checked 2026-09-18).
- The long-lived token dies after 60 days ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), 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](https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token), checked 2026-09-18). Details: [access tokens](https://electricblaze.com/developer/instagram-feed/access-token.html).
- `media_product_type`, the field that separates reels from other videos, comes back only when requested by name ([Meta: IG Media reference](https://developers.facebook.com/docs/instagram-platform/reference/instagram-media), checked 2026-09-18).

app/lib/instagram.server.ts:

```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:

```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](https://electricblaze.com/developer/instagram-feed/static-site.html#own-code) 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:

```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](https://reactrouter.com/upgrading/v7), 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.

## Related pages

- [React and Vite](https://electricblaze.com/developer/instagram-feed/react.html)
- [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html)
- [Static HTML site](https://electricblaze.com/developer/instagram-feed/static-site.html)
- [Access tokens](https://electricblaze.com/developer/instagram-feed/access-token.html)
- [Media URL expired](https://electricblaze.com/developer/instagram-feed/media-url-expired.html)
- [Feed schema](https://electricblaze.com/developer/instagram-feed/schema.html)

---

ElectricBlaze, updated 2026-09-18. Source of this page: https://electricblaze.com/developer/instagram-feed/react-router.html
