Instagram feed in SvelteKit: widget, server load and prerendering

A SvelteKit 2 and Svelte 5 guide to Instagram posts: where the widget script belongs, how to read the demo feed in a load function, and which environment module works when a route is prerendered.

At a glance

Status, checked 2026-09-18: the ElectricBlaze widget is how a SvelteKit app shows real Instagram posts today. In SvelteKit projects npx electricblaze add instagram-feed writes demo JSON and types only; real data for them waits for the 0.2 JSON API.

Use it when
the app runs SvelteKit 2 with Svelte 5 runes, on any adapter including adapter-static, and a route or layout should show recent Instagram posts.
Do not use it when
reels must look different from ordinary videos with real data today. The widget renderer does not read media_product_type, so it shows a reel as a plain video; request that field in your own Graph API code instead.
What the human does
the account owner signs in at a.electricblaze.com, sets up an Instagram Feed widget for their account and shares its id with you. That is the whole manual part.
Next step
with a widget id, add the Svelte component below to a route. Without one, run npx [email protected] add instagram-feed --json and render the demo feed from a load function.

Choosing an approach

SvelteKit can produce a feed at build time, on each request, or hand it to the browser. The approaches below map onto those modes.

Instagram feed approaches in SvelteKit
ApproachDataRendered byOwner's taskLimitation
Widget in <svelte:head>Real posts nowThe loader script, in the browserShare a widget idReels appear as plain videos; the Free plan counts 1000 views a month (pricing)
Demo feed in +page.server.tsTwelve sample postsServer or prerenderNoneReal data needs the 0.2 API
Own Graph API callReal posts nowServer or prerenderMeta app, token, refresh jobToken expiry after 60 days, expiring image links

If no one on the project will own a Meta app, take the widget. If you are designing the section before the owner signs up, begin with the demo feed. Write your own call when you need Instagram fields the widget leaves out.

The widget in a Svelte component

The dashboard gives the owner an embed like this one:

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

In SvelteKit, split it in two. The script tag goes into <svelte:head>; the container stays in the markup.

src/lib/components/InstagramWidget.svelte

<svelte:head>
  <script src="https://s.electricblaze.com/widget.js" defer></script>
</svelte:head>

<!-- Paste the widget id from the dashboard as a literal. The id class must come first. -->
<div class="electricblaze-id-WIDGET_ID"></div>

Render <InstagramWidget /> in any +page.svelte or +layout.svelte. To load the script on every route instead, put the same <script> line into src/app.html right after %sveltekit.head%.

Why the id is a literal, not a prop

The loader looks at the first class only, and it adds state classes to the container while it works. A class built from a prop, such as class="electricblaze-id-{widgetId}", is dynamic. When a dynamic class differs from the server HTML, Svelte 5 writes it again during hydration and drops the state classes. A literal class is static, and hydration leaves it untouched.

Client-side navigation

SvelteKit changes routes without reloading the page. The loader copes on its own: containers added later, for example after a client-side route change, are picked up; removed ones are released.

When <svelte:head> renders in the browser, Svelte 5 recreates its <script> elements so that they execute. A repeat execution is harmless, because the loader exits early when window.electricblaze already exists.

Grid or slider, columns and captions are edited in the dashboard. The Svelte side never grows beyond these few lines.

Demo feed in a server load

For SvelteKit the CLI writes data rather than a component. It sees @sveltejs/kit in package.json, selects the data kit and puts both files under src/lib/eb/, which SvelteKit exposes as $lib/eb/.

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

Printed by 0.1.2 in a SvelteKit project (dir and notes trimmed)

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

src/routes/+page.server.ts

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

export const prerender = true;

export const load: PageServerLoad = () => {
  const feed = demo as Feed;
  return { feed: { ...feed, posts: feed.posts.slice(0, 9) } };
};

src/routes/+page.svelte

<script lang="ts">
  import type { PageProps } from "./$types";
  let { data }: PageProps = $props();
</script>

<section class="ig">
  {#if data.feed.demo}<p class="ig-demo">demo feed</p>{/if}
  <ul>
    {#each data.feed.posts as post (post.id)}
      <li>
        <a href={post.url} target="_blank" rel="noopener">
          <img src={post.thumbnailUrl} alt={post.text} loading="lazy" />
          {#if post.type !== "image"}<span class="badge">{post.type}</span>{/if}
        </a>
      </li>
    {/each}
  </ul>
</section>

The load function runs on the server, and export const prerender = true turns the route into a static file at build time. let { data }: PageProps = $props() is the Svelte 5 form; PageProps exists since SvelteKit 2.16.

Posts follow schema v1. Show thumbnailUrl, link to url, badge the video and carousel types, and keep the "demo feed" label while data.feed.demo is true. There is no key to set yet: npx electricblaze connect instagram answers not_available_yet with exit code 2 until 0.2.

Own Graph API code and environment modules

You can skip ElectricBlaze and call Instagram from a server load. The owner then needs a Meta app and a professional account (Meta: Instagram Platform overview, checked 2026-09-18). Access for personal accounts through Basic Display ended on 2024-12-04 (Meta developer blog, 2024-09-04, checked 2026-09-18).

The token is the hard part. A long-lived one is valid for 60 days (Meta: access_token reference, checked 2026-09-18), and some job has to refresh it; see access tokens. Keep the request in src/lib/server/, a folder SvelteKit never lets client code import.

src/lib/server/instagram.ts

import { IG_TOKEN } from "$env/static/private";

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

export async function fetchInstagram(fetch: typeof globalThis.fetch, 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>>;
}

Call it from +page.server.ts as await fetchInstagram(fetch). Which environment module to import depends on how that route renders:

  • Prerendered route. Use $env/static/private. SvelteKit 2 refuses to read dynamic variables during prerendering (SvelteKit 2 migration guide, read 2026-09-18). Posts freeze at build time, so schedule rebuilds.
  • Route rendered per request. Use $env/dynamic/private and read env.IG_TOKEN. Static values are written into the bundle at build time, so only the dynamic module picks up a refreshed token without a rebuild.

On a per-request route, add setHeaders({ "cache-control": "public, max-age=600" }) in the load function so repeat visits can come from a cache instead of Instagram.

Image links from media_url point at Instagram's CDN and expire, which leaves a prerendered page with broken images after a while. Media URL expired covers why and what to do.

SvelteKit problems and fixes

  • Build error on $env/static/private. A .svelte file or a universal +page.ts imported it. Move the code into +page.server.ts or src/lib/server/. That error is the guard that keeps the token out of the browser.
  • Prerendering fails on $env/dynamic/private. Switch that route to the static module, or stop prerendering it.
  • The widget renders nothing. Either the first class is not electricblaze-id-..., or kit.csp in svelte.config.js blocks it. Allow https://s.electricblaze.com under script-src, plus https://api.electricblaze.com and https://proxy.electricblaze.com under connect-src.
  • Content jumps when the grid appears. Reserve height for .electricblaze-state-loading with :global(...) or a global stylesheet. Svelte drops scoped selectors for classes it never renders.

This page targets SvelteKit 2, the stable line on 2026-09-18. SvelteKit 3 is a release candidate that renames $lib to #lib and moves environment variables into src/env.ts, so adjust imports if you run it.

FAQ

Should the widget script go in svelte:head or app.html?

Either works. <svelte:head> loads it together with the component; src/app.html loads it on every page. The loader ignores a second copy, so having both does no harm.

Does the widget survive client-side navigation in SvelteKit?

Yes. The loader observes the document, so a container that appears after a route change gets mounted and one that disappears gets released.

Can I use $env/dynamic/private in a prerendered SvelteKit route?

No. SvelteKit 2 blocks dynamic environment variables while prerendering. Use $env/static/private there and rebuild when the token changes.

Does npx electricblaze add instagram-feed create a Svelte component?

No. In a SvelteKit project it writes src/lib/eb/feed.d.ts and src/lib/eb/demo/instagram.json. The grid is yours to write, about twenty lines.

Does the Instagram feed work with adapter-static?

Yes. The widget needs no server, and a prerendered load function reads the demo JSON during the build.