Blog / Developer tutorials

Embed an Instagram Feed on a Website With an API

By the InScrape API team · Published 2026-09-02 · 8 min read

A grid of image tiles being embedded into a browser frame

The job sounds small. A marketing site needs the client's latest twelve Instagram posts in a grid on the homepage, updated on its own, styled like the rest of the page.

It stays small right up until the images go blank about a day after launch. That failure is not a bug in your code, and it is the reason this post exists. Everything before it is straightforward; everything after it is a decision about where the bytes live.

If you only want the endpoint recipe — which calls to make, on what schedule, at what cost — Instagram API for a website feed is that page and it is shorter than this one. This post is the implementation: the code, and the media-hosting decision the recipe leaves open.

Start with the free embed — it is often the right answer

Before writing any code: open a public post, use the embed option in its menu, and paste the blockquote plus //www.instagram.com/embed.js snippet into the page. No API key, no token, no server. It renders the post with its caption and a link back.

Take this route when all of the following hold:

  • You need a handful of specific posts, not a self-updating feed
  • Meta's styling and layout are acceptable, because you cannot restyle the iframe
  • Loading Meta's JavaScript on your page is fine with your consent and privacy posture

Two things to know before you commit. The embed is a live iframe, so if the account goes private or the post is deleted, the block empties out on its own. And the oEmbed endpoint — graph.facebook.com/.../instagram_oembed, the programmatic version of that snippet — has required an app access token since October 2020, so "oEmbed" is not the token-free path people remember from 2019.

Where the official routes stop

There is no official Meta product that embeds an account's recent feed on an arbitrary website.

The Instagram Basic Display API was the closest thing, and Meta shut it down on 4 December 2024. What remains is the Graph API, which returns media for a Business or Creator account that has connected to your app through OAuth. If the feed you are rendering belongs to you, and you are willing to run the OAuth flow, hold the token, refresh it every sixty days and pass app review, that is a legitimate and free path.

It stops working the moment the answer to "whose feed?" is "the client's", or "a partner brand's", or "these eight accounts we sponsor". Those accounts will not be connecting to your app. For that shape of problem you need a read API for public data.

The API route, in four steps

  1. Fetch the handle's recent posts once per refresh window, server-side, with your API key
  2. Cache the response in your own layer so page renders do not each become an outbound call
  3. Re-host or proxy the media, because the URLs in the response are not durable
  4. Render from your cache
curl "https://api.socialscrape.dev/v1/instagram/posts?handle=natgeo&limit=12" \
  -H "x-api-key: $INSCRAPE_KEY"
{
  "success": true,
  "credits_charged": 1,
  "credits_remaining": 14903,
  "processing_time_ms": 1842,
  "requested_at": "2026-09-13T14:32:18Z",
  "query": { "username": "natgeo" },
  "data": {
    "handle": "natgeo",
    "next_cursor": "QVFEVWx6...",
    "posts": [
      {
        "shortcode": "C8QltIdyBGH",
        "type": "image",
        "caption": "Low tide on the estuary, shot at first light.",
        "like_count": 412903,
        "comment_count": 1804,
        "media_urls": ["https://scontent-.../466...jpg?...&oe=68B7F2C1"],
        "taken_at": "2026-09-01T14:03:11.000Z"
      }
    ]
  }
}

requested_at is the timestamp to keep with the row if you show engagement numbers later. Media arrives as a list per post, because a carousel has one URL per slide and a grid thumbnail only ever needs the first.

Step three is the one that gets skipped.

The part that breaks in week two: media URLs expire

Look at the query string on those media URLs. Instagram's CDN links are signed, and one of those parameters is an expiry. The oe value is a hexadecimal Unix timestamp — decode it and you have the exact second the link dies. After that the CDN returns 403 and your grid shows broken images.

Do not take a lifetime figure from me or from any blog post. Decode oe on a response you fetched yourself, because the window is set by Meta and can change without telling anyone. What is stable is the shape of the problem: it is measured in hours, not weeks.

This has one consequence that decides your architecture:

Writing a media URL into a database and rendering it later is not a caching strategy. It is a countdown.

It survives your local testing, it survives launch day, and it fails quietly on the following Tuesday for users you never hear from.

Fetching and rendering in Next.js

A server component with a cached fetch covers steps one, two and four. The key never reaches the browser.

type FeedPost = {
  shortcode: string;
  caption: string;
  media_urls: string[];
  like_count: number;
};

async function recentPosts(handle: string): Promise<FeedPost[]> {
  const res = await fetch(
    `https://api.socialscrape.dev/v1/instagram/posts?handle=${handle}&limit=12`,
    {
      headers: { "x-api-key": process.env.INSCRAPE_KEY! },
      next: { revalidate: 21600 },
    },
  );
  if (!res.ok) return [];
  const body = await res.json();
  return (body.data.posts as FeedPost[]).filter((p) => p.media_urls.length > 0);
}

export default async function Feed() {
  const posts = await recentPosts("natgeo");
  if (posts.length === 0) return null;

  return (
    <ul className="grid grid-cols-3 gap-2">
      {posts.map((p) => (
        <li key={p.shortcode}>
          <a href={`https://www.instagram.com/p/${p.shortcode}/`}>
            <img
              src={`/api/ig-image?src=${encodeURIComponent(p.media_urls[0])}`}
              alt={p.caption.slice(0, 120)}
              loading="lazy"
            />
          </a>
        </li>
      ))}
    </ul>
  );
}

Note the early return on a non-200. A feed block is decoration; on a bad day it should render as nothing rather than take the homepage down. Errors are never charged, so failing quietly costs you nothing but the empty grid.

Proxy or mirror

Two ways to keep the images alive, and they are not equivalent.

Proxy — stream the CDN bytes through your own route on each request. Simple, no storage, and it inherits the expiry: it works only while the signed URL your cache holds is still valid, so the proxy TTL must stay well under your feed refresh interval.

const ALLOWED = /(^|\.)(cdninstagram\.com|fbcdn\.net)$/;

export async function GET(req: Request) {
  const src = new URL(req.url).searchParams.get("src");
  if (!src) return new Response("missing src", { status: 400 });

  let target: URL;
  try {
    target = new URL(src);
  } catch {
    return new Response("bad src", { status: 400 });
  }
  if (target.protocol !== "https:" || !ALLOWED.test(target.hostname)) {
    return new Response("forbidden host", { status: 403 });
  }

  const upstream = await fetch(target, { cache: "no-store" });
  if (!upstream.ok) return new Response("upstream", { status: 502 });

  return new Response(upstream.body, {
    headers: {
      "content-type": upstream.headers.get("content-type") ?? "image/jpeg",
      "cache-control": "public, max-age=3600",
    },
  });
}

The host allowlist is not optional. A route that fetches whatever URL a query parameter names is a server-side request forgery hole pointed at your own network. Match the suffix rather than a single subdomain label — Instagram media is served from both scontent-<pop>.cdninstagram.com and deeper …fna.fbcdn.net names, and an allowlist that only accepts one label silently 403s half your grid.

Mirror — on ingest, download each new post's media once and put it in your own bucket keyed by shortcode and slide index, then render your own URLs. More moving parts, and it is the only version that survives the account deleting a post, your provider changing CDN hosts, or a refresh job failing for two days.

Proxy Mirror to your storage
Storage cost None Your bucket, forever
Survives URL expiry Only within the refresh window Yes
Survives a failed refresh job No Yes
Extra work per new post None One download and one upload
Right for A feed refreshed hourly or better Anything you consider durable content

What it costs

A feed refreshed every six hours is usually enough for a marketing site. That makes the arithmetic about how often you choose to refresh, not how often visitors load the page.

Refresh pattern Calls per day Credits per day Credits per month
Every 6 hours 4 4 ~120
Hourly refresh 24 24 ~720

Most marketing feeds do not need hourly API refreshes. Serve visitors from your own stored copy between refresh jobs.

At roughly 120 credits per handle per month, the 100 free credits on signup run a single feed for about twenty-five days, and Starter at $12 for 10,000 credits covers about 83 handles refreshed four times a day. If you also pull each new post individually through the single post endpoint, add one credit per new post — for an account posting five times a week, about twenty credits a month. The cost breakdown post has the full model.

What this does not do

Being explicit, because these come up in every second support thread:

  • Confirmed private accounts return 200 with available profile details, billed at the endpoint rate. If the client's account is private, there is no feed to embed and no configuration that changes that. Ask them to make it public or use the Graph API with their consent.
  • No posting, liking or commenting. This is read-only. Nothing here publishes to Instagram.
  • No reach, impressions or saves next to your embedded grid. Those are private metrics the account owner sees in the official API. What you get is public counts: likes, comments, plays.
  • No "who viewed the embed" from us — that is your own analytics, on your own page.

Engagement figures you display are the public counts at the moment of the request, not live numbers. Keep requested_at with the stored feed if you label freshness in the grid.

Where to go next

If the feed belongs to you and only to you, connect it through the Graph API and pay nothing. For every other feed on the page, this is the route.

Try it with 100 free credits.

No credit card, credits never expire, and failed requests are not charged.