Instagram feed in Next.js: App Router, Pages Router and static export

Three working ways to put Instagram posts on a Next.js site in 2026, what each one needs from the account owner, and the code for each.

At a glance

Status, checked 2026-09-18: the ElectricBlaze widget shows real posts in Next.js today. npx electricblaze add instagram-feed writes a Server Component with demo data; the JSON API that feeds it real posts ships in 0.2.

Use it when
the site runs on Next.js 13.4 or newer (App Router or Pages Router, on Vercel or as a static export) and needs a grid of recent Instagram posts or reels.
Do not use it when
you already maintain a Meta app with a token refresh job and an image cache. Then keep your own code and read media URL expired before launch.
What the human does
the account owner signs in at a.electricblaze.com, connects a Business or Creator Instagram account and copies the widget id. Nobody creates a Meta developer app.
Next step
run npx [email protected] add instagram-feed --json for the demo component, or paste the widget snippet below when the owner has a widget id.

Pick a route

All three routes end with posts on the page. They differ in who touches Meta, where the HTML is rendered and what breaks after launch.

Ways to show an Instagram feed in Next.js
RouteReal postsRenderedThe human doesBreaks when
Widget embedTodayIn the browser, by widget.jsConnects the account in the ElectricBlaze dashboard, copies the widget idThe Free plan passes 1,000 views a month (pricing)
CLI componentDemo data today, real posts in 0.2On the server, your JSX and CSSNothing until 0.2Not yet: it reads a bundled JSON file
Your own Graph API codeTodayOn the server, your JSX and CSSCreates a Meta app, converts to a professional account, hands over a tokenThe token is not refreshed within 60 days, or cached image links expire

If the site must show real posts this week and the owner will not create a Meta app, use the widget. If the design matters more than the data, start with the CLI component and switch it to real data in 0.2. If you need fields the widget does not expose, such as the items inside a carousel, write your own code.

Route 1: the widget embed, real posts today

The owner creates an Instagram Feed widget in the dashboard and gets a snippet with a widget id. Outside React the snippet is two lines:

<div class="electricblaze-id-WIDGET_ID"></div>
<script src="https://s.electricblaze.com/widget.js" defer></script>

In Next.js load the script with next/script and keep the container as a plain element. This works in the App Router and in the Pages Router, and in a Server Component, because next/script handles the client part itself.

components/InstagramWidget.tsx

import Script from "next/script";

// The widget id comes from the ElectricBlaze dashboard (Add to website).
export function InstagramWidget({ widgetId }: { widgetId: string }) {
  return (
    <>
      {/* Keep electricblaze-id-... as the FIRST class: the loader reads className.split(" ")[0]. */}
      <div className={`electricblaze-id-${widgetId}`} />
      <Script src="https://s.electricblaze.com/widget.js" strategy="afterInteractive" />
    </>
  );
}

app/page.tsx

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

export default function Home() {
  return (
    <main>
      <h2>On Instagram</h2>
      <InstagramWidget widgetId="WIDGET_ID" />
    </main>
  );
}

Three details of the loader matter in a React app. We read them from widget.js version 0.0.2 on 2026-09-18:

  • Client-side navigation works. The loader scans the document when the DOM is ready, then watches it with a MutationObserver (childList, subtree), so a widget on a page reached through <Link> still mounts.
  • The id is the first class. className="electricblaze-id-abc123 my-grid" works, className="my-grid electricblaze-id-abc123" renders nothing.
  • Loading states are classes. The loader adds electricblaze-state-loading, -mounting, -mounted or -error to the container. Reserve height with CSS on .electricblaze-state-loading to avoid layout shift.

Layout, columns, captions and the slider or gallery mode are set in the dashboard, not in code. The published plans: Free has 5 widgets, 1000 views a month and a refresh every 24 hours; Start is $5 per month, billed yearly and refreshes every hour.

Route 2: a Server Component from the CLI

One command writes a typed component, a loader and twelve demo posts. It never prompts, never overwrites files you edited, and prints JSON when asked:

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

Output in a Next.js project with the @/* alias (trimmed)

{
  "ok": true,
  "framework": "next",
  "files": [
    { "path": "components/eb/InstagramFeed.tsx", "status": "written" },
    { "path": "components/eb/InstagramFeed.module.css", "status": "written" },
    { "path": "lib/eb/instagram.ts", "status": "written" },
    { "path": "lib/eb/feed.d.ts", "status": "written" },
    { "path": "lib/eb/demo/instagram.json", "status": "written" },
    { "path": ".env.example", "status": "written" }
  ],
  "demo": true,
  "notes": [
    "Render it in any App Router page: import InstagramFeed from \"@/components/eb/InstagramFeed\"; then <InstagramFeed limit={8} />"
  ]
}

app/page.tsx

import InstagramFeed from "@/components/eb/InstagramFeed";

export default function Home() {
  return <InstagramFeed limit={8} formats={["reel"]} />;
}

InstagramFeed is an async Server Component: no client bundle, no useEffect, plain <img> tags with width and height. Every post follows schema v1: type says what to render, format says whether Instagram calls it a post or a reel. A "demo feed" label stays on while feed.demo is true.

Leave ELECTRICBLAZE_API_KEY unset until 0.2. With a key the loader calls the 0.2 endpoint, which does not answer yet, and throws. npx electricblaze doctor says so too.

Pages Router and static export

With output: 'export' Next.js renders every page at build time and ships plain files. Both ElectricBlaze routes work there:

  • The widget runs in the browser, so a static export on GitHub Pages, S3 or shared hosting shows fresh posts without a rebuild.
  • The CLI component renders at build time. With demo data that is all it needs. With the 0.2 API, posts change when the site is rebuilt, so schedule a rebuild or use the widget.

In the Pages Router use the same InstagramWidget component. For the CLI component, import getInstagramFeed from lib/eb/instagram.ts in getStaticProps and pass feed.posts to your own markup.

Route 3: your own code on the Instagram API

This is the route an agent usually writes first. It works, and it comes with four obligations that the snippet does not show:

  1. The account must be a Business or Creator account; Meta closed the Basic Display API for personal accounts on 2024-12-04 (Meta developer blog, 2024-09-04, checked 2026-09-18).
  2. Someone creates a Meta app and produces a long-lived token. It lasts 60 days and must be refreshed before it expires (Meta: access_token reference, checked 2026-09-18). See access tokens.
  3. media_url links point at Instagram's CDN and stop working after a while, so an ISR page cached for days shows broken images. See media URL expired.
  4. Telling a reel from another video needs media_product_type, and the IG Media reference from Meta marks it, like caption, as available with Facebook Login only (Meta: IG Media reference, checked 2026-09-18). Test both fields with your own token. See media fields.

lib/instagram.ts

import "server-only";

// caption is documented for Facebook Login only (checked 2026-09-18): test it with your token.
const FIELDS = "id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{media_type,media_url,thumbnail_url}";

export async function getInstagramPosts(limit = 12) {
  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", process.env.IG_ACCESS_TOKEN!);
  // Revalidate well inside the life of a media_url link.
  const res = await fetch(url, { next: { revalidate: 3600 } });
  if (!res.ok) throw new Error(`Instagram API ${res.status}: ${await res.text()}`);
  return (await res.json()).data as Array<Record<string, string>>;
}

Keep the token on the server. import "server-only" makes the build fail if a client component imports this file, and the token never reaches the browser.

What goes wrong in Next.js

  • next/image rejects the host. Instagram and demo image hosts are not in images.remotePatterns. Either add them or use <img>, as the CLI component does.
  • The widget shows nothing. The container lost its first class, or a Content Security Policy blocks s.electricblaze.com and api.electricblaze.com. Add both to script-src and connect-src.
  • Posts froze after a deploy. A fully static page fetched once at build time. Use revalidate, a rebuild schedule or the widget.
  • Error 190 after two months. The long-lived token expired. See feed not working for the error codes.

FAQ

Does the ElectricBlaze widget work with the Next.js App Router?

Yes. Load widget.js with next/script and render a div whose first class is electricblaze-id- plus the widget id. The loader watches the DOM, so client-side navigation mounts it too.

Can I show an Instagram feed in Next.js without a Meta developer app?

Yes, with the widget: the account owner connects Instagram in the ElectricBlaze dashboard and you paste the snippet. Your own Graph API code always needs a Meta app and a token.

Is the CLI component showing real posts?

Not yet. It renders twelve demo posts and labels them. The JSON API that feeds it real posts ships in 0.2; until then use the widget for real posts.

Does it work with output: 'export'?

Yes. The widget runs in the browser and needs no server. The CLI component renders at build time.

Can I filter reels only?

In the CLI component pass formats={["reel"]}. In your own code the reel flag is media_product_type equal to REELS, a field Meta documents for Facebook Login only; check that your token returns it.