Instagram feed in Nuxt 4: widget, server route and route rules

What goes where in a Nuxt 4 project with the app/ directory: the widget script, the demo feed, a private Instagram token, and the cache rules that decide how fresh the posts are.

At a glance

Status, checked 2026-09-18: in Nuxt, real Instagram posts come from the ElectricBlaze widget today. npx electricblaze add instagram-feed gives a Nuxt project sample data plus types, not a Vue component; real data through the JSON API arrives in 0.2.

Use it when
the project runs Nuxt 4 (source in app/) with server rendering, nuxt generate or a mix of both, and a page needs recent Instagram posts.
Do not use it when
the grid must be server-rendered Vue markup with real posts right now. The widget draws on the client and the CLI data is a demo, so that case needs your own server route and a Meta app.
What the human does
the account owner opens a.electricblaze.com, builds an Instagram Feed widget, links their Instagram account to it and passes the widget id to the developer. No Meta app or token changes hands.
Next step
with a widget id, copy the component below. Without one, run npx [email protected] add instagram-feed --json and serve the demo feed from server/api/.

Where each option runs

A Nuxt page can get posts from the browser, from a Nitro server route, or from a file read at build time. The table matches the three options to those places.

Instagram feed options in Nuxt 4
OptionReal postsRuns inOwner providesWatch out for
Widget via useHeadYes, todayThe browserA widget idNeeds <ClientOnly>; Free plan: 5 widgets, 1000 views a month (pricing)
Demo JSON from the CLINo, demo until 0.2Nitro or the buildNothingFiles land in lib/eb/, outside app/
Server route on the Graph APIYes, todayNitroMeta app, token, refreshToken lifetime and expiring image links

Start with the widget unless you have a reason not to. It is the only option with real posts that asks nothing technical of the owner. The server route suits teams that already operate a Meta app.

Widget component with useHead and ClientOnly

This is the embed code the dashboard hands out:

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

In Nuxt, the script moves into useHead and the container into a component template.

app/components/InstagramWidget.vue

<script setup lang="ts">
useHead({
  script: [{ src: "https://s.electricblaze.com/widget.js", defer: true }],
});
</script>

<template>
  <ClientOnly>
    <!-- electricblaze-id-... has to stay the first class -->
    <div class="electricblaze-id-WIDGET_ID" />
    <template #fallback>
      <div class="ig-placeholder" />
    </template>
  </ClientOnly>
</template>

Use it in a page as <InstagramWidget />. Nuxt auto-imports everything in app/components/, so no import line is needed.

Why the container sits in ClientOnly

The loader adds electricblaze-state-loading, -mounting, -mounted or -error to the container, then fills it with the grid. If that happens before Vue hydrates the page, Vue meets markup it did not render and can report a hydration mismatch. <ClientOnly> creates the container only after hydration. The #fallback slot holds its place in the server HTML; give .ig-placeholder a height to avoid a jump.

The loader still finds the late container, because it keeps a MutationObserver on the whole document. The same observer handles <NuxtLink> navigation: a container on the next page mounts without a reload.

To load the script on every page, list it under app.head.script in nuxt.config.ts instead. Loading it twice is harmless; the second copy stops at its window.electricblaze check.

Gallery or slider, columns, rows and captions are configured in the dashboard. The Vue component has no props for them.

Demo feed through server/api/ and useAsyncData

The CLI has no Vue template yet. When it finds nuxt in package.json, it writes the feed JSON and its types and nothing more.

The files land in lib/eb/ at the project root, not in app/. The CLI only checks for a src/ directory, and Nuxt 4 keeps its source in app/. In Nuxt 4, ~ points at app/ and ~~ at the root, so app code imports them as ~~/lib/eb/....

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

Result from 0.1.2 in a Nuxt 4 project (dir and notes trimmed)

{
  "ok": true,
  "framework": "data",
  "files": [
    {
      "path": "lib/eb/feed.d.ts",
      "status": "written"
    },
    {
      "path": "lib/eb/demo/instagram.json",
      "status": "written"
    }
  ],
  "demo": true,
  "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."
}

Serve the file from a server route. The page fetches /api/instagram and never learns where the data came from, so the source can change later without touching Vue code.

server/api/instagram.get.ts

import type { Feed } from "../../lib/eb/feed";
import demo from "../../lib/eb/demo/instagram.json";

export default defineEventHandler(() => demo as Feed);

app/pages/index.vue

<script setup lang="ts">
const { data: feed } = await useAsyncData("instagram", () => $fetch("/api/instagram"));
</script>

<template>
  <section v-if="feed" class="ig">
    <p v-if="feed.demo" class="ig-demo">demo feed</p>
    <ul>
      <li v-for="post in feed.posts.slice(0, 9)" :key="post.id">
        <NuxtLink :to="post.url" target="_blank">
          <img :src="post.thumbnailUrl" :alt="post.text" loading="lazy" />
          <span v-if="post.type !== 'image'" class="badge">{{ post.type }}</span>
        </NuxtLink>
      </li>
    </ul>
  </section>
</template>

useAsyncData runs the request on the server while rendering and ships the result to the browser in the payload, so the grid is not fetched twice. Nitro types $fetch("/api/instagram") from the route, so feed arrives as a typed schema v1 object.

Leave the "demo feed" tag visible as long as feed.demo stays true. The command meant for real data answers like this today, with exit code 2:

npx electricblaze connect instagram --json on 2026-09-18

{
  "ok": false,
  "command": "connect",
  "status": "not_available_yet",
  "message": "\"connect\" needs the ElectricBlaze API and ships in 0.2. Everything else works in demo mode today.",
  "next": "Keep the demo feed for now; read https://electricblaze.com for the API status."
}

Server route with a private token

Writing the Instagram call yourself fits Nuxt well: the token stays in Nitro and the page keeps its useAsyncData call. The owner's side is heavier:

Declare the token in runtimeConfig outside public, then set NUXT_IG_TOKEN in the host's environment. Nuxt maps a NUXT_ variable only when its key is declared in nuxt.config.ts.

nuxt.config.ts

export default defineNuxtConfig({
  runtimeConfig: {
    igToken: "", // filled from NUXT_IG_TOKEN at runtime; server only
  },
  routeRules: {
    "/api/instagram": { swr: 3600 },
    "/": { isr: 3600 }, // Netlify and Vercel; use prerender: true for a static build
  },
});

server/api/instagram.get.ts

const FIELDS = "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp";

export default defineEventHandler(async (event) => {
  const { igToken } = useRuntimeConfig(event);
  const res = await $fetch<{ data: Record<string, string>[] }>("https://graph.instagram.com/me/media", {
    query: { fields: FIELDS, limit: 9, access_token: igToken },
  });
  return res.data;
});

useRuntimeConfig(event) reads the value when the request arrives, so a refreshed token applies without a rebuild. Anything under runtimeConfig.public is sent to every browser; the token must never go there.

$fetch throws when Instagram returns an error status, and Nitro turns that into a 500 from /api/instagram. Show a fallback in the page through the error ref of useAsyncData.

Return schema v1 from the route and the demo template above keeps working: map permalink to url, caption to text, and thumbnail_url or media_url to thumbnailUrl.

Caching with routeRules

  • swr: 3600 on /api/instagram keeps the route output in the server cache for an hour and renews it in the background, so Instagram is called roughly once an hour per server cache rather than once per visitor.
  • isr: 3600 on the page lets the CDN hold the HTML. The Nuxt rendering docs name Netlify and Vercel as the platforms for it (read 2026-09-18).
  • prerender: true, or nuxt generate for the whole site, renders at build time. The route runs once, and the posts stay as they were until the next build.

Whatever you cache, the media_url links inside expire on Instagram's side, so an old cache or build ends up with broken images. Media URL expired explains the lifetime problem.

Nuxt-specific problems

  • Import of ~/lib/eb/feed fails. In Nuxt 4 ~ means app/. Use ~~/lib/eb/feed or a relative path.
  • NUXT_IG_TOKEN is set but igToken is empty. The key is missing from runtimeConfig in nuxt.config.ts, and Nuxt ignores variables it has no key for.
  • Hydration warning next to the widget. The container is rendered on the server. Wrap it in <ClientOnly>.
  • Posts never change on a generated site. nuxt generate called the API route once. Deploy with a server and swr, or use the widget, which fetches in the browser.
  • Token expired after two months. The long-lived token was never refreshed. Feed not working lists the symptoms.

FAQ

Why does the CLI write to lib/eb/ and not to app/ in Nuxt 4?

CLI 0.1.2 uses src/ only when that directory exists. Nuxt 4 keeps source in app/, so the files go to the project root. Import them with the ~~ alias.

Does the widget need ClientOnly in Nuxt?

It is the safe choice. The loader edits the container, possibly before Vue hydrates it; creating the container on the client keeps that edit out of hydration.

Does the widget keep working after NuxtLink navigation?

Yes. The loader watches the document for new containers, so the widget mounts on the next page without a full reload.

How do I keep the Instagram token out of the browser in Nuxt?

Put it in private runtimeConfig, set it through NUXT_IG_TOKEN, and read it only under server/ with useRuntimeConfig(event).

Can I use nuxt generate for an Instagram feed?

Yes. The widget works on a generated site because it runs in the browser. A server route runs once at build time, so its posts stay fixed until you generate again.