Instagram feed on a static HTML site: shared hosting, GitHub Pages, Netlify Drop

A static site has no server to hide a token on. Three setups still work in September 2026: an embed, a local kit with demo posts, and a scheduled job that commits JSON and images to the repository.

At a glance

Status, checked 2026-09-18: the ElectricBlaze widget shows real posts on any static page today: paste two lines of HTML and upload. npx electricblaze add instagram-feed writes a plain HTML kit with demo posts; real data through the JSON API ships in 0.2.

Use it when
the site is hand-written HTML, CSS and JavaScript with no build step, served from shared hosting, GitHub Pages or a Netlify Drop folder, and it needs a strip of recent Instagram posts.
Do not use it when
the site lives in a GitHub repository and someone will own a Meta app and renew its token every 60 days. The scheduled Action at the end of this page then shows real posts without any third-party service.
What the human does
the owner creates an Instagram Feed widget at a.electricblaze.com, connects the account and copies the embed code. For the GitHub Action route they create a Meta app and a token instead.
Next step
paste the embed code where the grid belongs and upload the page. For a local demo first, run npx [email protected] add instagram-feed --json inside the site folder.

Three setups compared

Every setup below keeps the Instagram token out of the page source. A static site cannot bend that rule: a token inside a <script> is readable by anyone who opens View Source.

Instagram feed setups for a site without a server
SetupPostsYou needNew posts appearToken kept in
Widget embedReal, todayAn ElectricBlaze accountPublished refresh: every 24 hours on Free, every hour on StartThe ElectricBlaze account
CLI HTML kitDemo until 0.2Node.js 18 or newer on your computer, onceNever; the file is staticNo token exists
Scheduled GitHub ActionReal, todayA GitHub repository, a Meta app, a professional accountOn each scheduled runGitHub repository secrets

Use the embed when the owner wants real posts with the least upkeep. Use the kit to lay out the page before any account exists. Use the Action when nobody wants a hosted service and someone accepts the token chores.

The widget embed: paste and upload

The owner copies this code from the dashboard. It is the whole integration:

index.html

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

Put the div where the posts should appear. The script can follow it directly or sit before </body>. One copy of the script serves every widget on the page.

  • Shared hosting (cPanel, FTP). Edit the HTML file and upload it over the old one. The server needs no configuration, because the browser fetches the script from s.electricblaze.com.
  • GitHub Pages. Commit the edited HTML to the publishing branch; the next Pages build serves it.
  • Netlify Drop. Deploy the updated folder the same way the first version went up.

The loader looks for an element whose first class starts with electricblaze-id-. A site template that prepends its own class, as in class="block electricblaze-id-...", gets an empty box. The loader keeps observing the page after load, so a container that a tab or accordion script inserts later still mounts.

Posts are drawn by JavaScript in the visitor's browser. They are not in the HTML file, so crawlers that skip scripts do not see captions.

The Free plan includes 5 widgets on unlimited websites and 1000 views a month, with an ElectricBlaze credit link under the widget. Start drops the credit for $5 per month, billed yearly (pricing).

The CLI kit: demo posts, no account

Run the CLI inside the site folder. With no package.json there, it writes the plain HTML kit. In a folder that has one, --framework=html forces the same kit.

cd my-site
npx [email protected] add instagram-feed --json

Output in a folder without package.json (dir and notes removed)

{
  "ok": true,
  "component": "instagram-feed",
  "source": "instagram",
  "framework": "html",
  "files": [
    { "path": "electricblaze/instagram-feed.js", "status": "written" },
    { "path": "electricblaze/instagram-feed.css", "status": "written" },
    { "path": "electricblaze/instagram.json", "status": "written" },
    { "path": "electricblaze/instagram.demo.js", "status": "written" },
    { "path": "electricblaze/feed.d.ts", "status": "written" }
  ],
  "demo": true,
  "snippet": [
    "<link rel=\"stylesheet\" href=\"electricblaze/instagram-feed.css\">",
    "<div data-eb-feed=\"instagram\" data-limit=\"8\"></div>",
    "<script src=\"electricblaze/instagram.demo.js\"></script>",
    "<script src=\"electricblaze/instagram-feed.js\" defer></script>"
  ],
  "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."
}

Paste the four snippet lines into the page and upload the electricblaze/ folder next to it. Node runs on your computer only; the host serves plain files. The container accepts four attributes:

  • data-limit="8" sets how many posts to show.
  • data-formats="reel" keeps reels only; post keeps everything else.
  • data-header="false" hides the avatar and handle row.
  • data-src="feed.json" reads a feed from a URL instead of the demo script. The file must follow schema v1.

While feed.demo is true, the kit prints a small "demo feed" tag. Leave it in place until the posts are real.

Your own code: a scheduled GitHub Action

A static site can show real posts without a hosted service if Instagram is called from CI instead of from the browser. GitHub Actions runs a script on a timer. The token sits in repository secrets. The script writes a JSON file and the images, and the job commits them next to the pages.

Three things come first:

  1. A Business or Creator account connected to a Meta app. The Instagram API does not serve personal accounts (Meta: Instagram Platform overview, checked 2026-09-18).
  2. A long-lived token saved as the repository secret IG_ACCESS_TOKEN. It lasts 60 days (Meta: access_token reference, checked 2026-09-18). The job cannot store a refreshed token by itself, since GITHUB_TOKEN has no permission for secrets. Renew it by hand before day 60, or give a separate job a fine-grained token that may run gh secret set. See access tokens.
  3. Files instead of links. media_url and thumbnail_url point at Instagram's CDN and expire, so the script downloads every image into the repository. Background: media URL expired.

.github/workflows/instagram.yml

name: Instagram feed

on:
  schedule:
    - cron: "23 */6 * * *"   # UTC, every 6 hours, away from the top of the hour
  workflow_dispatch:

permissions:
  contents: write

jobs:
  refresh:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: node scripts/instagram.mjs
        env:
          IG_ACCESS_TOKEN: ${{ secrets.IG_ACCESS_TOKEN }}
      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add instagram
          git diff --staged --quiet || git commit -m "Refresh Instagram feed"
          git push

scripts/instagram.mjs

// Runs in GitHub Actions only. The token never reaches the site's files.
import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";

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

const api = new URL("https://graph.instagram.com/me/media");
api.searchParams.set("fields", FIELDS);
api.searchParams.set("limit", "12");
api.searchParams.set("access_token", process.env.IG_ACCESS_TOKEN ?? "");

const res = await fetch(api);
if (!res.ok) throw new Error(`Instagram API ${res.status}: ${await res.text()}`);
const { data } = await res.json();

await mkdir(OUT, { recursive: true });
const posts = [];
for (const m of data) {
  // A video's still image is thumbnail_url; skip anything without an image URL.
  const src = m.media_type === "VIDEO" ? m.thumbnail_url : m.media_url;
  if (!src) continue;
  const file = `${OUT}/${m.id}.jpg`;
  if (!existsSync(file)) {
    const img = await fetch(src);
    if (!img.ok) continue;
    await writeFile(file, Buffer.from(await img.arrayBuffer()));
  }
  posts.push({
    id: m.id,
    url: m.permalink,
    image: file,
    caption: m.caption ?? "",
    reel: m.media_product_type === "REELS",
    publishedAt: m.timestamp,
  });
}

// Delete images of posts that dropped out of the latest 12.
const keep = new Set(posts.map((p) => `${p.id}.jpg`));
for (const name of await readdir(OUT)) {
  if (name.endsWith(".jpg") && !keep.has(name)) await rm(`${OUT}/${name}`);
}

// No timestamp here: a run with no new posts leaves the files unchanged.
await writeFile(`${OUT}/feed.json`, JSON.stringify({ posts }, null, 2) + "\n");

We ran the script against a stubbed API on 2026-09-18. A second run with the same posts left every file byte for byte identical, so the commit step had nothing to commit. Images already on disk are not downloaded twice, and images of old posts are deleted.

instagram/feed.json as the script writes it

{
  "posts": [
    {
      "id": "17912345678901234",
      "url": "https://www.instagram.com/reel/EXAMPLE/",
      "image": "instagram/17912345678901234.jpg",
      "caption": "How we cup a new lot in under 60 seconds.",
      "reel": true,
      "publishedAt": "2026-09-17T08:30:00+0000"
    }
  ]
}

index.html

<ul id="instagram" class="ig-grid"></ul>
<style>
  .ig-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 4px; padding: 0; list-style: none; }
  .ig-grid img { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
</style>
<script>
  // Reads the committed file; the page never sees a token.
  fetch("instagram/feed.json")
    .then((res) => res.json())
    .then(({ posts }) => {
      const list = document.getElementById("instagram");
      for (const post of posts.slice(0, 8)) {
        const img = Object.assign(document.createElement("img"), {
          src: post.image, alt: post.caption.slice(0, 120), loading: "lazy",
        });
        const link = Object.assign(document.createElement("a"), {
          href: post.url, target: "_blank", rel: "noopener",
        });
        link.append(img);
        const item = document.createElement("li");
        item.append(link);
        list.append(item);
      }
    });
</script>

The page builds DOM nodes rather than an HTML string, so a caption can never inject markup. To reuse the CLI kit instead, write the file in schema v1 and point data-src at it.

Schedule rules and deploys

  • The cron line runs in UTC, and five minutes is the shortest interval GitHub accepts. Runs can be delayed under load, most often at the start of the hour, hence minute 23.
  • The job needs permissions: contents: write to push its commit.
  • Schedules run on the latest commit of the default branch. In a public repository GitHub switches them off after 60 days without repository activity, and a quiet account commits nothing, so check the Actions tab now and then (GitHub Docs: schedule, checked 2026-09-18).
  • Hosts that build from the repository through their own Git integration pick up the push like any other. Netlify Drop has no repository, so this route needs a Git-connected site.

GitHub Pages is the exception. Per the Pages troubleshooting docs, commits pushed with GITHUB_TOKEN "do not trigger a GitHub Pages build" (GitHub Docs, checked 2026-09-18). Set the Pages source to GitHub Actions and deploy from the same job:

.github/workflows/instagram.yml, GitHub Pages version of the job

permissions:
  contents: write
  pages: write
  id-token: write

jobs:
  refresh:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
    steps:
      # checkout, setup-node, the script and the commit step from above, then:
      - uses: actions/configure-pages@v6
      - uses: actions/upload-pages-artifact@v5
        with:
          path: .
      - uses: actions/deploy-pages@v5

upload-pages-artifact leaves out .git, .github and other dotfiles by default, and the token never touches the uploaded folder.

FAQ

Can I put my Instagram access token in the JavaScript of a static site?

No. Anyone can copy it from View Source or the network panel and call the API as the account until it expires. Keep it with ElectricBlaze (widget) or in GitHub repository secrets (Action).

Does the ElectricBlaze widget work on GitHub Pages?

Yes. It is a div and a script tag; the page stays static, and the posts load in the visitor's browser.

Do I need Node.js on the web server?

No. The CLI runs once on your computer and writes plain files. The widget needs only its two lines of HTML.

How often does the GitHub Action route update the feed?

As often as the cron line says, never more than once every 5 minutes. The example runs every 6 hours and commits only when posts change.

Will search engines index the Instagram posts?

Not with the widget or the small renderer above; both insert posts with JavaScript. To get captions into the HTML, let the Action write the markup into the page instead of a JSON file.