Meta retired the Instagram Basic Display API on December 4, 2024. The most common thing it powered was small: a row of recent posts in a website footer or a "follow us" section. This guide rebuilds exactly that, without an Instagram login or a Meta app.
You will map the two Basic Display calls a feed widget used onto two ScrapingBot endpoints, wrap them in a module that returns posts in the old shape, cache the result so visitors never wait on Instagram, and serve the images yourself. The responses and output below are real, from the public @nasa account, trimmed with … where they run long.
Before you start
- A ScrapingBot API key. Create a free account for 100 credits. No card needed.
- Node.js 18 or newer, for the built-in
fetch. The code was run on Node 24. There is nothing tonpm install. - The username of a public Instagram account. Private accounts cannot be read.
What changes, and what does not
Basic Display read the account that had logged in to your app and granted it a token. The replacement reads public data by username, so there is no token to store, refresh or lose when someone leaves the company. The flip side is that only public accounts work. For a brand's own website feed that is almost always fine, since the account is public anyway.
A typical widget made two calls. Here is where each one goes:
| Basic Display call | Replacement | Notes |
|---|---|---|
GET /me?fields=id,username,media_count | /user/by_username | Send username. Returns id, username and media_count, plus name, bio and follower counts. media_count can be null. |
GET /me/media?fields=id,caption,media_type,media_url,permalink,thumbnail_url,timestamp | /medias/by_user_id | Send the id from the profile as user_id, and count (1 to 50). Returns edges[].node with the post fields. |
The media fields have different names but the same information:
| Old field | Where it is now |
|---|---|
id | node.id |
caption | node.caption.text |
media_type | node.media_type, a number: 1 photo, 2 video, 8 carousel. node.product_type is clips for a reel. |
media_url | node.display_url for images; the first node.video_versions[].url for videos. |
thumbnail_url | node.display_url on a video is its cover image. |
permalink | Build it from node.code: https://www.instagram.com/p/<code>/, or /reel/<code>/ for reels. |
timestamp | node.taken_at, Unix seconds. Convert with new Date(taken_at * 1000). |
You also get like_count and comment_count on every post, which Basic Display never offered.
What the posts call returns
curl -X POST https://scrapingbot.io/api/v1/instagram \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/medias/by_user_id", "params": {"user_id": "528817151", "count": 12}}'
{
"success": true,
"data": {
"count": 6,
"edges": [
{
"node": {
"id": "3983374110243288826",
"code": "DdHyaYAifb6",
"taken_at": 1789075224,
"media_type": 8,
"product_type": "carousel_container",
"like_count": 119578,
"comment_count": 960,
"caption": { "text": "Cementing their names in history.\n\nThe Artemis III crew is leaving their mark — literally.…" },
"display_url": "https://scontent-xxc1-1.cdninstagram.com/v/t51.82787-15/805027638_…_n.jpg?stp=…&oe=6AC6BFF2",
"timeline_pinned_user_ids": ["528817151"],
…
}
},
…
],
"page_info": { "end_cursor": "3997832394388741461_528817151", "has_next_page": true, … }
},
"duration": "0.93",
"statusCode": 200,
"creditsUsed": 5
}
Two details from this real response shape the code below. We asked for 12 and got 6; Instagram picks the page size, so treat count as a maximum. And the first post is from September 10 while later ones are from October: NASA pinned it, and timeline_pinned_user_ids says so. Pinned posts come first, so a "latest posts" widget has to sort by taken_at itself.
Instagram image URLs expire
The display_url and video URLs are signed. The oe parameter at the end of the URL above encodes when the link expires, and after that the image stops loading. If your page hot-links Instagram's URLs, the grid breaks a few days later. Download each image when you refresh and serve your own copy.
The feed module
Save this as instagram-feed.mjs. getFeed(username) returns the account and its posts in the Basic Display field names, with two additions: like_count / comment_count, and image, the path of the local copy.
// instagram-feed.mjs: the latest public posts of one account, in the old Basic Display shape,
// cached in memory and on disk, with the images downloaded next to the cache.
import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
import { join } from 'node:path';
const API = 'https://scrapingbot.io/api/v1/instagram';
const KEY = process.env.SCRAPINGBOT_API_KEY;
const REFRESH_MS = 30 * 60 * 1000; // refresh at most every 30 minutes
export const CACHE_DIR = process.env.FEED_CACHE_DIR || './instagram-cache';
const TYPES = { 1: 'IMAGE', 2: 'VIDEO', 8: 'CAROUSEL_ALBUM' };
async function call(endpoint, params, spent) {
const res = await fetch(API, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ endpoint, params }),
signal: AbortSignal.timeout(60_000),
});
const body = await res.json();
if (!body.success) throw new Error(`${endpoint} failed (${res.status}): ${body.error}`);
spent.credits += body.creditsUsed ?? 0;
return body.data;
}
// Was: GET /me?fields=id,username,media_count
async function getUser(username, spent) {
const p = await call('/user/by_username', { username }, spent);
if (p.is_private) throw new Error(`@${p.username} is private; only public accounts work`);
return { id: p.id, username: p.username, full_name: p.full_name, media_count: p.media_count ?? null };
}
// Was: GET /me/media?fields=id,caption,media_type,media_url,permalink,thumbnail_url,timestamp
async function getMedia(userId, spent) {
const data = await call('/medias/by_user_id', { user_id: userId, count: 12 }, spent);
return (data.edges ?? []).map(({ node }) => toBasicDisplay(node));
}
function toBasicDisplay(n) {
const type = TYPES[n.media_type] ?? 'IMAGE';
const video = type === 'VIDEO' ? n.video_versions?.[0]?.url : undefined;
return {
id: n.id,
caption: n.caption?.text ?? '',
media_type: type,
media_url: video ?? n.display_url,
permalink: `https://www.instagram.com/${n.product_type === 'clips' ? 'reel' : 'p'}/${n.code}/`,
thumbnail_url: video ? n.display_url : undefined,
timestamp: new Date(n.taken_at * 1000).toISOString(),
like_count: n.like_count ?? null,
comment_count: n.comment_count ?? null,
pinned: (n.timeline_pinned_user_ids ?? []).length > 0,
image: `/instagram/img/${n.code}.jpg`, // our own copy; Instagram's URLs expire
};
}
// Instagram media URLs are signed and stop working after a while, so keep a copy of each image.
async function saveImage(post) {
const file = join(CACHE_DIR, 'img', post.image.split('/').pop());
if (await access(file).then(() => true, () => false)) return false;
const res = await fetch(post.thumbnail_url ?? post.media_url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`image ${res.status} for ${post.permalink}`);
await writeFile(file, Buffer.from(await res.arrayBuffer()));
return true;
}
async function refresh(username, cached) {
const spent = { credits: 0 };
const user = cached?.user ?? (await getUser(username, spent)); // the user ID never changes
const posts = (await getMedia(user.id, spent))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // pinned posts come first otherwise
await mkdir(join(CACHE_DIR, 'img'), { recursive: true });
let saved = 0;
for (const post of posts) if (await saveImage(post)) saved++;
const feed = { user, posts, fetchedAt: Date.now() };
await writeFile(join(CACHE_DIR, `${username}.json`), JSON.stringify(feed, null, 2));
console.log(`refreshed @${user.username}: ${posts.length} posts, ${saved} new images, ${spent.credits} credits`);
return feed;
}
const memory = new Map();
const inflight = new Map();
export async function getFeed(username) {
let feed = memory.get(username);
if (!feed) {
feed = await readFile(join(CACHE_DIR, `${username}.json`), 'utf8').then(JSON.parse, () => null);
if (feed) memory.set(username, feed);
}
if (feed && Date.now() - feed.fetchedAt < REFRESH_MS) return feed;
// One refresh at a time per account, however many visitors arrive at once.
if (!inflight.has(username)) {
inflight.set(username, refresh(username, feed)
.then((fresh) => { memory.set(username, fresh); return fresh; })
.finally(() => inflight.delete(username)));
}
try {
return await inflight.get(username);
} catch (err) {
if (!feed) throw err;
console.error(`refresh failed, serving the cached feed: ${err.message}`);
return feed; // a stale feed beats an empty widget
}
}
How it keeps a website fast and cheap:
- Two cache layers. The feed lives in memory and in
instagram-cache/<username>.json. A restart reads the file, so a deploy does not cost a call as long as the cache folder survives it. Within 30 minutes of the last refresh,getFeed()never touches the network. - The account ID is fetched once. It never changes, so after the first run a refresh is a single
/medias/by_user_idcall: 5 credits instead of 10. - One refresh at a time. If fifty visitors arrive when the cache has just expired, they all wait on the same request instead of starting fifty.
- A failed refresh serves the old feed. A stale grid is better than an empty one. Only a cold start with no cache at all throws.
- Images are saved once. Each file is named after the post's
code, and existing files are skipped, so a refresh only downloads posts that are new.
media_type comes back as IMAGE, VIDEO or CAROUSEL_ALBUM, the values Meta's current Instagram media reference documents for that field, and thumbnail_url is only set on videos, as there. If your front end already switched on those values, it keeps working.
Render the grid
Here is a small server with no framework. / renders the six newest posts as a grid, /feed.json returns the feed for a client-side widget, and /instagram/img/… serves the saved images. In Express or Next.js, the same three handlers become three routes.
// server.mjs: a tiny site that renders the feed as a grid. Run: node server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getFeed, CACHE_DIR } from './instagram-feed.mjs';
const USERNAME = process.env.IG_USERNAME || 'nasa';
const PORT = Number(process.env.PORT || 3000);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function render({ user, posts }, limit = 6) {
const tiles = posts.slice(0, limit).map((p) => `
<a class="tile" href="${esc(p.permalink)}" target="_blank" rel="noopener">
<img src="${esc(p.image)}" alt="${esc(p.caption.split('\n')[0].slice(0, 120))}" loading="lazy">
<span>${p.like_count?.toLocaleString('en-US') ?? ''} likes</span>
</a>`).join('');
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Latest from @${esc(user.username)}</title>
<style>
body { font: 16px system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
.tile { position: relative; display: block; aspect-ratio: 1; overflow: hidden; border-radius: 6px; }
.tile img { width: 100%; height: 100%; object-fit: cover; }
.tile span { position: absolute; left: 8px; bottom: 8px; color: #fff; font-size: 13px;
background: rgb(0 0 0 / .55); padding: 2px 8px; border-radius: 4px; }
</style></head><body>
<h2>Latest from <a href="https://www.instagram.com/${esc(user.username)}/">@${esc(user.username)}</a></h2>
<div class="grid">${tiles}</div>
</body></html>`;
}
createServer(async (req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
try {
if (pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(render(await getFeed(USERNAME)));
}
if (pathname === '/feed.json') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(await getFeed(USERNAME)));
}
const img = pathname.match(/^\/instagram\/img\/([\w-]+\.jpg)$/);
if (img) {
const bytes = await readFile(join(CACHE_DIR, 'img', img[1]));
res.writeHead(200, { 'content-type': 'image/jpeg', 'cache-control': 'public, max-age=86400' });
return res.end(bytes);
}
res.writeHead(404).end('Not found');
} catch (err) {
console.error(err.message);
res.writeHead(err.code === 'ENOENT' ? 404 : 502).end('Feed unavailable');
}
}).listen(PORT, () => console.log(`http://localhost:${PORT}`));
Everything from Instagram goes through esc() before it reaches the HTML. Captions are user-written text, and a caption with a < or a quote in it should not break your page. The image route only accepts names made of letters, digits, _ and -, so it cannot be used to read other files.
Run it
export SCRAPINGBOT_API_KEY=YOUR_API_KEY
IG_USERNAME=nasa node server.mjs
The first page view fetches the profile and the posts, downloads six images and writes the cache:
http://localhost:3000
refreshed @nasa: 6 posts, 6 new images, 10 credits
Further views in the next 30 minutes print nothing, because they are served from memory. After the cache expires, even across a restart, the next view makes a single call and downloads only new images:
refreshed @nasa: 6 posts, 0 new images, 5 credits
The start of /feed.json. The newest post is now first, even though the API listed three pinned posts ahead of it:
{
"user": { "id": "528817151", "username": "nasa", "full_name": "NASA", "media_count": null },
"posts": [
{
"id": "3999305299946080602",
"caption": "Swipe for countless stars\n\nSagittarius B2 is the most massive and most active star-forming region in the Milky Way.…",
"media_type": "CAROUSEL_ALBUM",
"media_url": "https://scontent-xxc1-1.cdninstagram.com/v/t51.82787-15/831222439_…_n.jpg?…",
"permalink": "https://www.instagram.com/p/DeAYvsnn4la/",
"timestamp": "2026-10-02T20:52:45.000Z",
"like_count": 491879,
"comment_count": 1768,
"pinned": false,
"image": "/instagram/img/DeAYvsnn4la.jpg"
},
{
"id": "3998494441249448613",
"caption": "Godspeed, Crew-13. 🚀\n\n…",
"media_type": "IMAGE",
"permalink": "https://www.instagram.com/p/Dd9gYJnDCKl/",
"timestamp": "2026-10-01T18:01:49.000Z",
…
},
…
],
"fetchedAt": …
}
What it costs to run
The widget's cost depends only on how often you refresh, not on traffic. With the account ID cached, each refresh is one 5-credit call:
| Refresh every | Calls a month | Credits a month | On Starter |
|---|---|---|---|
| 30 minutes | 1,440 | 7,200 | about $1.31 |
| 1 hour | 720 | 3,600 | about $0.65 |
| 6 hours | 120 | 600 | about $0.11 |
Pick the interval from how often the account posts; change REFRESH_MS to suit. Failed calls are refunded, and the module keeps serving the cached feed while it retries on the next view.
Where to go from here
- Show reels instead of posts:
/reels/by_user_idworks the same way withmax_idpaging. Get a public Instagram profile and its reels walks through it, including the play counts Instagram often leaves empty. - The Basic Display replacement page covers what else you can read now, such as comments and any public account.
- The Instagram API page and the posts reference list every endpoint and parameter.
Common questions
What replaced the Instagram Basic Display API?
Meta retired Basic Display on December 4, 2024, and there is no drop-in successor for reading a personal account. For showing a public account's posts on a website, call /user/by_username once for the account ID, then /medias/by_user_id for the latest posts, and cache the result.
Do I need a Meta app, app review or access token?
No. Requests are authenticated with your ScrapingBot API key. There is no Instagram login, no token to refresh and nothing for the account owner to approve.
Why do my Instagram images stop loading after a few days?
The image URLs Instagram returns are signed and expire. If your page links to them directly, they break once the signature runs out. Download each image when you refresh the feed and serve your own copy, as the module in this guide does.
Does it work for private accounts?
No. Only public accounts and their public posts are available. The module checks is_private and throws a clear error rather than caching an empty feed.
How much does a website feed cost to run?
Each call is 5 credits. With the account ID cached, a refresh is one call, so refreshing every 30 minutes is 240 credits a day, about 7,200 a month: roughly $1.31 on the Starter plan ($49.99 for 275,000 credits). Visitors never trigger extra calls.