At a glance
Status, checked 2026-09-18: real Instagram posts reach an Astro site through the ElectricBlaze widget today. For Astro, npx electricblaze add instagram-feed writes demo JSON and types only; the JSON API with real data ships in 0.2.
- Use it when
- the site is built with Astro 5 or newer, deploys to Cloudflare, Netlify or any static host, and one section should list recent Instagram posts.
- Do not use it when
- real posts must be baked into the prerendered HTML today. The widget draws in the browser and the CLI JSON is sample data, so before 0.2 only your own build-time code does that.
- What the human does
- the site owner creates an Instagram Feed widget at a.electricblaze.com, connects the Instagram account there and sends you the widget id. No Meta developer app is involved.
- Next step
- paste the widget component below if a widget id exists. Otherwise run
npx [email protected] add instagram-feed --jsonand build the grid from the demo JSON.
Three options for Astro
Astro renders at two moments: during the build and, with an adapter, on each request. The widget adds a third moment, the visitor's browser. Each option below lives in one of them.
| Option | Posts | Runs at | Needs from the owner | Weak spot |
|---|---|---|---|---|
| ElectricBlaze widget | Real, today | Visitor's browser | A widget id from the dashboard | Posts are not in the static HTML; the Free plan includes 1000 views a month (pricing) |
| CLI demo JSON | Sample data until 0.2 | Build | Nothing | Twelve fixed posts from a fictional coffee roaster |
| Your own Graph API code | Real, today | Build or request | A Meta app, a professional account, a token | The token lasts 60 days and media_url links expire |
Pick the widget when the owner wants real posts soon and nobody will look after a Meta app. Pick the demo JSON when the layout comes first and the account can wait. Pick your own code when posts have to exist in the HTML at build time.
The widget as an Astro component
The owner copies a two-line embed from the dashboard:
<div class="electricblaze-id-WIDGET_ID"></div>
<script src="https://s.electricblaze.com/widget.js" defer></script>
Wrap it in a component and mark the script is:inline. Astro processes and bundles <script> tags by default. The Astro docs require is:inline for scripts that come from outside src/, such as a CDN (read 2026-09-18). With the directive Astro emits the tag exactly as written.
src/components/InstagramWidget.astro
---
interface Props {
/** From the ElectricBlaze dashboard, "Add to website". */
widgetId: string;
}
const { widgetId } = Astro.props;
---
<!-- electricblaze-id-... must be the first class on this element -->
<div class={`electricblaze-id-${widgetId}`}></div>
<script is:inline src="https://s.electricblaze.com/widget.js" defer></script>
Drop <InstagramWidget widgetId="abc123" /> into any page or layout. The grid appears once the page has loaded. Columns, rows, captions and the choice between gallery and slider are chosen in the dashboard, so the component takes no layout props.
The loader reads the id from the first class only. Your own classes may follow it, never precede it.
View Transitions with ClientRouter
With <ClientRouter /> from astro:transitions in the layout, Astro fetches the next page and replaces the whole <body> instead of doing a full load. We read widget.js 0.0.2 on 2026-09-18 to check how it copes:
- It scans the document when the DOM is ready, then watches it with a MutationObserver (childList, subtree). The observer sits on
documentitself, so a swapped-in<body>that carries a widget container is noticed and mounted. - Astro may execute an inline script again when a visitor comes back to a page. That costs nothing: the loader checks for
window.electricblazefirst and a second copy exits.
You therefore need neither data-astro-rerun nor transition:persist for the widget. Moving the <script is:inline> tag into the layout <head> also works, because Astro leaves head scripts in place when the next page has them too.
To avoid a jump when the grid arrives, give .electricblaze-state-loading a min-height in a global stylesheet or a <style is:global> block.
Demo JSON in the frontmatter
The CLI ships no Astro component. It finds astro in package.json, switches to its data-only kit and writes two files: the feed and its types.
npx [email protected] add instagram-feed --json
Output of 0.1.2 in an Astro project that has a src/ directory (notes 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": [
"astro 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."
],
"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."
}
Import the JSON in a component frontmatter. The import is resolved during the build, so the browser receives plain HTML with <img> tags and no feed code.
src/components/InstagramFeed.astro
---
import type { Feed } from "../lib/eb/feed";
import demo from "../lib/eb/demo/instagram.json";
interface Props { limit?: number }
const { limit = 9 } = Astro.props;
const feed = demo as Feed;
const posts = feed.posts.slice(0, limit);
---
<section class="ig">
{feed.demo && <p class="ig-demo">demo feed</p>}
<ul>
{posts.map((post) => (
<li>
<a href={post.url} target="_blank" rel="noopener">
<img src={post.thumbnailUrl} alt={post.text} loading="lazy" />
{post.type !== "image" && <span class="badge">{post.type}</span>}
</a>
</li>
))}
</ul>
</section>
<style>
.ig ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 4px; padding: 0; list-style: none; }
.ig a { position: relative; display: block; }
.ig img { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
.badge { position: absolute; top: 6px; right: 6px; font-size: 12px; }
</style>
Each post follows schema v1. Images, videos and carousels always carry thumbnailUrl, so one <img> per tile is enough. type chooses the badge, and format separates a reel from a regular post.
The sample media mixes square, 4:5, 9:16 and 16:9 shapes. Keep the "demo feed" label for as long as feed.demo is true. npx electricblaze connect instagram exits with code 2 today; real data for this component comes with the 0.2 API.
Your own code: at build time or in an endpoint
Calling the Instagram API directly from Astro is possible. The owner has to supply three things first:
- A professional account. The Instagram API with Instagram Login does not serve personal accounts (Meta: Instagram Platform overview, checked 2026-09-18). The Basic Display API that once did reached end of life on 2024-12-04 (Meta developer blog, 2024-09-04, checked 2026-09-18).
- A Meta app and a long-lived token. It is valid for 60 days (Meta: access_token reference, checked 2026-09-18), so a scheduled job must refresh it. Access tokens walks through that job.
- Tolerance for image links that die.
media_urlpoints at Instagram's CDN and those URLs expire; media URL expired has the details.
Declare the token as a server secret with astro:env. Avoid import.meta.env here: since Astro 6 its values are always inlined into the build output (Astro v6 upgrade guide, read 2026-09-18). Secrets from astro:env/server stay out of the bundle. A PUBLIC_ prefix would hand the token to every visitor.
astro.config.mjs
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
schema: {
IG_TOKEN: envField.string({ context: "server", access: "secret" }),
},
},
});
src/lib/instagram.ts
import { IG_TOKEN } from "astro:env/server";
const FIELDS = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp";
export async function getInstagramMedia(limit = 9) {
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", IG_TOKEN);
const res = await fetch(url);
if (!res.ok) throw new Error(`Instagram API ${res.status}`);
return (await res.json()).data as Array<Record<string, string>>;
}
Called from the frontmatter of a prerendered page, getInstagramMedia() runs once per build. The posts then stay as they were on build day, and their image links break later. Rebuild on a schedule through a Cloudflare or Netlify deploy hook, or render on request.
Rendering on request needs @astrojs/cloudflare or @astrojs/netlify, and the adapter supplies astro:env secrets at runtime.
src/pages/api/instagram.json.ts
import type { APIRoute } from "astro";
import { getInstagramMedia } from "../../lib/instagram";
// Rendered per request by the Cloudflare or Netlify adapter.
export const prerender = false;
export const GET: APIRoute = async () => {
const media = await getInstagramMedia();
return Response.json(media, { headers: { "Cache-Control": "public, max-age=600" } });
};
A page can opt out of prerendering the same way: put export const prerender = false in its frontmatter and call the helper there. In both cases the token remains in server code and the browser receives only post data.
When the feed misbehaves in Astro
- Empty box on the deployed site. Check the class order first. Then check the Content Security Policy, often set in a
_headersfile on Cloudflare and Netlify:script-srcmust allowhttps://s.electricblaze.com, andconnect-srcmust allowhttps://api.electricblaze.comandhttps://proxy.electricblaze.com. - Posts frozen on build day. A frontmatter fetch on a static page ran once. Schedule rebuilds, set
export const prerender = false, or switch to the widget. - Broken thumbnails on an older build. Instagram image links expired. Feed not working lists the causes and fixes.
- Token works locally, fails in production.
IG_TOKENexists in.envbut not in the Cloudflare or Netlify environment.