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.jsonlistsnext, orreact-router.config.tsexists, 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.htmland render<InstagramWidget widgetId="..." />, or runnpx [email protected] add instagram-feed --jsonfor 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.
| 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
<!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_IDin.envis fine. The Instagram credentials never touch your code. - Pass layout classes after the id. The component above does this for you. A
classNamethat starts withmt-6or any other class leaves the container empty. - React 19 alternative. A component may render
<script src="..." async />; React moves it into<head>and loads eachsrconce (react.dev, checked 2026-09-18). Theindex.htmltag avoids the React version question. - Hold the space. Give
.electricblaze-state-loadingamin-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:
- 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).
- 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 setbefore it runs out. See access tokens. - Image links age.
media_urlpoints 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 beforeelectricblaze-id-.... Only the first class is read. - The token appears in
dist/assets. Client code read it throughimport.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_jwtis still on while the app sends a publishable key rather than a user JWT. Set it tofalseinconfig.tomland deploy again. - The function log shows an expired-token error. Sixty days passed without renewal. Feed not working lists the Graph API errors.