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 --jsoninside 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.
| Setup | Posts | You need | New posts appear | Token kept in |
|---|---|---|---|---|
| Widget embed | Real, today | An ElectricBlaze account | Published refresh: every 24 hours on Free, every hour on Start | The ElectricBlaze account |
| CLI HTML kit | Demo until 0.2 | Node.js 18 or newer on your computer, once | Never; the file is static | No token exists |
| Scheduled GitHub Action | Real, today | A GitHub repository, a Meta app, a professional account | On each scheduled run | GitHub 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;postkeeps 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:
- 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).
- 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, sinceGITHUB_TOKENhas no permission for secrets. Renew it by hand before day 60, or give a separate job a fine-grained token that may rungh secret set. See access tokens. - Files instead of links.
media_urlandthumbnail_urlpoint 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
cronline 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: writeto 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.