Instagram feed in Astro: widget, build-time JSON and an on-demand endpoint

How to show Instagram posts on an Astro 5, 6 or 7 site deployed to Cloudflare or Netlify, what survives a fully static build, and where the Instagram token is allowed to live.

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

Instagram feed options for an Astro site
OptionPostsRuns atNeeds from the ownerWeak spot
ElectricBlaze widgetReal, todayVisitor's browserA widget id from the dashboardPosts are not in the static HTML; the Free plan includes 1000 views a month (pricing)
CLI demo JSONSample data until 0.2BuildNothingTwelve fixed posts from a fictional coffee roaster
Your own Graph API codeReal, todayBuild or requestA Meta app, a professional account, a tokenThe 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 document itself, 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.electricblaze first 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:

  1. 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).
  2. 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.
  3. Tolerance for image links that die. media_url points 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 _headers file on Cloudflare and Netlify: script-src must allow https://s.electricblaze.com, and connect-src must allow https://api.electricblaze.com and https://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_TOKEN exists in .env but not in the Cloudflare or Netlify environment.

FAQ

Does the ElectricBlaze widget work with Astro ClientRouter?

Yes. Astro swaps the <body> on navigation, and the loader watches the whole document with a MutationObserver, so it mounts the container in the new body. A second run of the script does nothing.

Why does the widget script need is:inline in Astro?

Astro bundles <script> tags by default. A file served from s.electricblaze.com has to stay a plain tag, and is:inline keeps it that way.

Can a fully static Astro site show real Instagram posts?

Yes, with the widget: it fetches posts client-side, so no server is required. Your own build-time code also works, but its posts stay the same until the next build.

Where does the CLI put its files in an Astro project?

In src/lib/eb/: feed.d.ts holds the types and demo/instagram.json holds twelve demo posts. A project without src/ gets them in lib/eb/.

Can the Instagram token go in an Astro client script?

No. Every visitor can read client code. Keep the token in astro:env/server and call Instagram from frontmatter or an on-demand endpoint.