# Instagram feed in a React app built with Vite: Lovable, Bolt and hand-written SPAs

**Status, checked 2026-09-18:** the ElectricBlaze widget shows real posts in a Vite React app today: one script tag in `index.html` and one `div`. `npx electricblaze add instagram-feed` writes typed demo data only; the JSON API for real posts ships in 0.2.

> A Vite single-page app has no server, so the real question is where the Instagram token lives. Three answers that work in September 2026, with tested code for each.

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

## At a glance

- **Use it when:** the project is a client-rendered React app built with Vite, the way Lovable and Bolt generate it, and one page needs recent Instagram posts. You do not need a server of your own.
- **Do not use it when:** a server of yours already calls the Instagram API and someone refreshes the token, or the grid must label reels: the widget draws a reel as an ordinary video. If `package.json` lists `next`, or `react-router.config.ts` exists, go to [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html) or [React Router](https://electricblaze.com/developer/instagram-feed/react-router.html).
- **What the human does:** the account owner signs in at [a.electricblaze.com](https://a.electricblaze.com/), adds an Instagram Feed widget, connects Instagram there and sends you the widget id. They never open the Meta developer dashboard.
- **Next step:** add the loader tag to `index.html` and render `<InstagramWidget widgetId="..." />`, or run `npx electricblaze@0.1.2 add instagram-feed --json` for demo data and types.

## Where the token can live in a single-page app

A Vite build is a folder of static files, and a visitor can read whatever the browser downloads. That rules out the pattern agents reach for first: a `fetch` to `graph.instagram.com` with a token taken from `import.meta.env.VITE_IG_TOKEN`.

> VITE_* variables should not contain sensitive information such as API keys.
>
> [Vite: Env Variables and Modes](https://vite.dev/guide/env-and-mode), Vite 8.3, checked 2026-09-18

Vite copies every `VITE_` value into the JavaScript bundle at build time. Each option below keeps the token somewhere else.

| Option | Posts | Secret stored | Code you maintain | Owner's task |
|---|---|---|---|---|
| ElectricBlaze widget | Real, today | Nothing in your project | One script tag and a `div` | Connects Instagram in the ElectricBlaze dashboard |
| CLI data kit | Demo until 0.2 | None | Your own component over typed JSON | Nothing yet |
| Supabase Edge Function | Real, today | Instagram token in Supabase secrets | Function, token renewal, error handling | Creates a Meta app and a token |

Choose the widget for real posts this week. Choose the CLI kit while you build the design. Choose the Edge Function only if the project already runs on Supabase and a named person will own the token.

## The widget: a script tag and a div

Load `widget.js` once, from `index.html`. It is not an npm package and needs no React wrapper library.

index.html:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Blaze Coffee</title>
    <script src="https://s.electricblaze.com/widget.js" defer></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

src/components/InstagramWidget.tsx:

```tsx
type Props = { widgetId: string; className?: string };

export function InstagramWidget({ widgetId, className }: Props) {
  // widget.js reads the id from the first class only: extra classes go after it.
  const classes = ["electricblaze-id-" + widgetId, className].filter(Boolean).join(" ");
  return <div className={classes} />;
}
```

src/pages/Index.tsx:

```tsx
import { InstagramWidget } from "@/components/InstagramWidget";

export default function Index() {
  return (
    <section className="py-16">
      <h2 className="text-2xl font-semibold">On Instagram</h2>
      <InstagramWidget widgetId={import.meta.env.VITE_EB_WIDGET_ID} className="mt-6" />
    </section>
  );
}
```

Lovable maps `@/` to `src/`. In a bare `npm create vite` project, import the component with a relative path; that is how we built it with the Vite 8 React template and TypeScript 6 (`tsc -b && vite build`, 2026-09-18).

React mounts the `div` after the first scan by the loader, and that is fine. We read `widget.js` 0.0.2 on 2026-09-18: it keeps a MutationObserver on the document and mounts any matching element that shows up later, including after an in-app route change.

- **The widget id is public.** It ends up in the rendered markup anyway, so `VITE_EB_WIDGET_ID` in `.env` is fine. The Instagram credentials never touch your code.
- **Pass layout classes after the id.** The component above does this for you. A `className` that starts with `mt-6` or any other class leaves the container empty.
- **React 19 alternative.** A component may render `<script src="..." async />`; React moves it into `<head>` and loads each `src` once ([react.dev](https://react.dev/reference/react-dom/components/script), checked 2026-09-18). The `index.html` tag avoids the React version question.
- **Hold the space.** Give `.electricblaze-state-loading` a `min-height`, and the page will not jump when the posts arrive.

Grid size, captions and slider mode are widget settings in the dashboard, not props. Per the pricing page, a view is counted when a page containing the widget is opened and the widget loads. Free allows 1000 of those a month and refreshes every 24 hours; Start costs $5 per month, billed yearly and refreshes every hour ([pricing](https://electricblaze.com/pricing.html)).

## Demo data from the CLI, your component

Version 0.1.2 has no React component template. When `package.json` lists `vite`, `add` chooses the `data` kit and writes two files: the schema types and twelve demo posts.

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

Output in a Vite project with a `src/` folder (`dir` removed, second note trimmed):

```json
{
  "ok": true,
  "component": "instagram-feed",
  "source": "instagram",
  "framework": "data",
  "files": [
    { "path": "src/lib/eb/feed.d.ts", "status": "written" },
    { "path": "src/lib/eb/demo/instagram.json", "status": "written" }
  ],
  "demo": true,
  "notes": [
    "vite 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.",
    "Read src/lib/eb/demo/instagram.json in the framework's loader ... Types: src/lib/eb/feed.d.ts."
  ],
  "next": "To show real posts today, connect the account in the ElectricBlaze widget and paste its embed snippet (https://electricblaze.com). The JSON API for this component (npx electricblaze connect instagram) ships in 0.2. Until then this is a demo feed."
}
```

The component is yours. This one fits in 25 lines and takes its types from `feed.d.ts`:

src/components/InstagramGrid.tsx:

```tsx
import type { Feed } from "../lib/eb/feed";
import demo from "../lib/eb/demo/instagram.json";

const feed = demo as Feed;

export function InstagramGrid({ limit = 8, formats }: { limit?: number; formats?: string[] }) {
  const posts = feed.posts.filter((p) => !formats || formats.includes(p.format)).slice(0, limit);
  return (
    <section aria-label={`Instagram posts by @${feed.origin.name}`}>
      {feed.demo && <small>demo feed</small>}
      <ul className="ig-grid">
        {posts.map((p) => (
          <li key={p.id}>
            <a href={p.url} target="_blank" rel="noopener noreferrer">
              <img src={p.thumbnailUrl} alt={p.media[0]?.alt ?? p.text} loading="lazy"
                   width={p.media[0]?.width} height={p.media[0]?.height} />
              {p.type === "video" && <span>{p.format === "reel" ? "Reel" : "Video"}</span>}
              {p.type === "carousel" && <span>{p.media.length} items</span>}
            </a>
          </li>
        ))}
      </ul>
    </section>
  );
}
```

Vite imports JSON without a plugin. The demo feed mixes square, 4:5, 9:16 and 16:9 media, so the grid needs `object-fit: cover` on the images. `formats={["reel"]}` keeps reels only, because each post carries `format` next to `type` ([schema v1](https://electricblaze.com/developer/instagram-feed/schema.html)).

Keep the label while `feed.demo` is `true`. Only a connected account, which needs the 0.2 API, sets it to `false`.

## Your own token: a Supabase Edge Function

Many Lovable projects already have a Supabase backend, which makes an Edge Function the closest server. It keeps the token out of the bundle. The Meta side of the work stays with you:

1. The Instagram account must be professional. The API with Instagram Login serves Business and Creator accounts ([Meta: Instagram Platform overview](https://developers.facebook.com/docs/instagram-platform/overview), checked 2026-09-18); the Basic Display API that served personal accounts ended 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).
2. A long-lived token stays valid for 60 days ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18). Someone renews it and stores the new value with `supabase secrets set` before it runs out. See [access tokens](https://electricblaze.com/developer/instagram-feed/access-token.html).
3. Image links age. `media_url` points at Instagram's CDN, so serve fresh links on every call and never cache the JSON for days. See [media URL expired](https://electricblaze.com/developer/instagram-feed/media-url-expired.html).

Supabase now documents functions as a default export with a `fetch` handler wrapped in `withSupabase`, not `Deno.serve` ([Supabase AI prompt for Edge Functions](https://supabase.com/docs/guides/ai-tools/ai-prompts/edge-functions), checked 2026-09-18). The wrapper also answers CORS preflight requests.

supabase/functions/instagram-feed/index.ts:

```ts
import { withSupabase } from "npm:@supabase/server@^1";

const FIELDS = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp";

export default {
  // "publishable": the caller must send the project's publishable key, as supabase-js does.
  fetch: withSupabase({ auth: "publishable" }, async () => {
    const url = new URL("https://graph.instagram.com/me/media");
    url.searchParams.set("fields", FIELDS);
    url.searchParams.set("limit", "12");
    url.searchParams.set("access_token", Deno.env.get("IG_ACCESS_TOKEN") ?? "");
    const res = await fetch(url);
    if (!res.ok) {
      console.error("Instagram API", res.status, await res.text());
      return Response.json({ error: "instagram_unavailable" }, { status: 502 });
    }
    const { data } = await res.json();
    return Response.json({ posts: data });
  }),
};
```

supabase/config.toml:

```toml
[functions.instagram-feed]
verify_jwt = false
```

```sh
supabase secrets set IG_ACCESS_TOKEN=your-long-lived-token
supabase functions deploy instagram-feed
```

A publishable key is not a JWT, so the gateway check is off and `withSupabase` validates the key instead.

src/hooks/useInstagramPosts.ts:

```ts
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";

export type IgPost = {
  id: string;
  permalink: string;
  media_type: "IMAGE" | "VIDEO" | "CAROUSEL_ALBUM";
  media_url?: string;
  thumbnail_url?: string;
  caption?: string;
};

export function useInstagramPosts() {
  const [posts, setPosts] = useState<IgPost[]>([]);
  useEffect(() => {
    supabase.functions.invoke<{ posts: IgPost[] }>("instagram-feed").then(({ data, error }) => {
      if (error) console.error("instagram-feed", error);
      else setPosts(data?.posts ?? []);
    });
  }, []);
  return posts;
}
```

Lovable keeps the Supabase client in `src/integrations/supabase/client.ts`; other projects import their own `createClient` instance. The hook type-checks against `@supabase/supabase-js` 2.116. For a video, render `thumbnail_url`; for other types, `media_url`.

Every page view becomes one Graph API call. Meta allows 4800 calls per 24 hours multiplied by the account's impressions ([Meta: rate limiting](https://developers.facebook.com/docs/graph-api/overview/rate-limiting), checked 2026-09-18), so a busy page on a small account can run out. Cache the response for minutes, not days.

## When the feed does not show

- **Empty `div`, no console error.** Some other class comes before `electricblaze-id-...`. Only the first class is read.
- **The token appears in `dist/assets`.** Client code read it through `import.meta.env`. Treat it as leaked: create a new token, delete the old value from `.env`, and move the call into the Edge Function.
- **401 from the function.** `verify_jwt` is still on while the app sends a publishable key rather than a user JWT. Set it to `false` in `config.toml` and deploy again.
- **The function log shows an expired-token error.** Sixty days passed without renewal. [Feed not working](https://electricblaze.com/developer/instagram-feed/feed-not-working.html) lists the Graph API errors.

## FAQ

### Can a React app call the Instagram API straight from the browser?

Only by handing the access token to every visitor, because Vite inlines `VITE_` variables into the bundle. Call the API from an Edge Function, or use the widget, which keeps the Instagram connection on the ElectricBlaze side.

### Does the widget survive client-side routing in a Vite SPA?

Yes. `widget.js` watches the DOM and mounts containers that appear after a navigation. Load it once in `index.html`; a second copy of the script does nothing.

### Does npx electricblaze add instagram-feed generate a React component?

Not in 0.1.2. In a Vite project it writes `feed.d.ts` and a demo feed, and the `InstagramGrid` component on this page reads both. Real posts through the CLI arrive with the JSON API in 0.2.

### Is the widget id a secret?

No. Every site that embeds a widget prints its id in the page. Putting it in `.env` is a convenience, not protection.

### Do I need Supabase for an Instagram feed in Lovable?

No. The widget and the CLI kit need no backend. Supabase matters only for your own Graph API code.

## Related pages

- [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html)
- [React Router and Remix](https://electricblaze.com/developer/instagram-feed/react-router.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)
- [CLI reference](https://electricblaze.com/developer/instagram-feed/cli.html)

---

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