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

**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.

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

Canonical: https://electricblaze.com/developer/instagram-feed/nuxt.html

## At a glance

- **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](https://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 electricblaze@0.1.2 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.

| Option | Real posts | Runs in | Owner provides | Watch out for |
|---|---|---|---|---|
| Widget via `useHead` | Yes, today | The browser | A widget id | Needs `<ClientOnly>`; Free plan: 5 widgets, 1000 views a month ([pricing](https://electricblaze.com/pricing.html)) |
| Demo JSON from the CLI | No, demo until 0.2 | Nitro or the build | Nothing | Files land in `lib/eb/`, outside `app/` |
| Server route on the Graph API | Yes, today | Nitro | Meta app, token, refresh | Token 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:

```html
<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:

```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/...`.

```sh
npx electricblaze@0.1.2 add instagram-feed --json
```

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

```json
{
  "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:

```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:

```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](https://electricblaze.com/developer/instagram-feed/schema.html) 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:

```json
{
  "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:

- a professional Instagram account, because the API with Instagram Login rejects personal ones ([Meta: Instagram Platform overview](https://developers.facebook.com/docs/instagram-platform/overview), checked 2026-09-18);
- a Meta app issuing a long-lived token, good for 60 days ([Meta: access_token reference](https://developers.facebook.com/docs/instagram-platform/reference/access_token), checked 2026-09-18), which a job you run must refresh;
- no fallback to Basic Display, which reached end of life on 2024-12-04 ([Meta developer blog, 2024-09-04](https://developers.facebook.com/blog/post/2024/09/04/update-on-instagram-basic-display-api/), checked 2026-09-18).

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:

```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:

```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](https://nuxt.com/docs/4.x/guide/concepts/rendering) 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](https://electricblaze.com/developer/instagram-feed/media-url-expired.html) 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](https://electricblaze.com/developer/instagram-feed/feed-not-working.html) 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.

## Related pages

- [Next.js](https://electricblaze.com/developer/instagram-feed/nextjs.html)
- [SvelteKit](https://electricblaze.com/developer/instagram-feed/sveltekit.html)
- [React Router](https://electricblaze.com/developer/instagram-feed/react-router.html)
- [Access token lifetime](https://electricblaze.com/developer/instagram-feed/access-token.html)
- [Fields for reels](https://electricblaze.com/developer/instagram-feed/media-fields.html)
- [Instagram API without Basic Display](https://electricblaze.com/developer/instagram-feed/basic-display-api-alternative.html)

---

ElectricBlaze, updated 2026-09-18. Source of this page: https://electricblaze.com/developer/instagram-feed/nuxt.html
