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

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.

At a glance

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.

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 or React Router.
What the human does
the account owner signs in at 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 [email protected] 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 copies every VITE_ value into the JavaScript bundle at build time. Each option below keeps the token somewhere else.

Three ways to show Instagram posts in a Vite React app
OptionPostsSecret storedCode you maintainOwner's task
ElectricBlaze widgetReal, todayNothing in your projectOne script tag and a divConnects Instagram in the ElectricBlaze dashboard
CLI data kitDemo until 0.2NoneYour own component over typed JSONNothing yet
Supabase Edge FunctionReal, todayInstagram token in Supabase secretsFunction, token renewal, error handlingCreates 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

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

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

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

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.

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

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

{
  "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

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

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, checked 2026-09-18); the Basic Display API that served personal accounts ended on 2024-12-04 (Meta developer blog, 2024-09-04, checked 2026-09-18).
  2. A long-lived token stays valid for 60 days (Meta: access_token reference, checked 2026-09-18). Someone renews it and stores the new value with supabase secrets set before it runs out. See access tokens.
  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.

Supabase now documents functions as a default export with a fetch handler wrapped in withSupabase, not Deno.serve (Supabase AI prompt for Edge Functions, checked 2026-09-18). The wrapper also answers CORS preflight requests.

supabase/functions/instagram-feed/index.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

[functions.instagram-feed]
verify_jwt = false
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

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