JavaScript Examples
Introduction
These examples call the Feedframer REST API with the Fetch API. They work in modern browsers and in Node.js 18 or later. Most of them use GET /api/v1/me, which returns your Instagram profile and your posts in one response. The REST API reference lists every parameter.
Without page[size], a request returns your plan's post limit: the 6 most recent posts on Free, or up to 100 posts per page on Premium.
Your API key in the browser
Code that runs in the browser sends your API key with every request, so anyone who opens the page's source or network tab can read it. The key is read-only. It can only read one Instagram account's posts and profile, which are already public on Instagram. For most sites that is an acceptable trade.
If a key leaks and you want to stop it working, press Regenerate on the account's card on the Dashboard. The old key stops working immediately, so update your site with the new one.
If you would rather keep the key out of the browser, call Feedframer from your own server. The "Keeping the key on a server" section below shows how.
Basic usage
Fetch your posts
const apiKey = 'YOUR_API_KEY';
const response = await fetch(`https://feedframer.com/api/v1/me?api_key=${apiKey}`);
const data = await response.json();
console.log(`@${data.username}: ${data.posts.length} posts`);
for (const post of data.posts) {
console.log(post.mediaType, post.timestamp, post.caption ?? '(no caption)');
}
Top-level await works in ES modules (<script type="module">, or .mjs files in Node.js). Elsewhere, wrap the code in an async function.
Build query strings with URLSearchParams
Parameter names such as filter[type] contain square brackets. URLSearchParams encodes them for you:
const params = new URLSearchParams({
api_key: 'YOUR_API_KEY',
'filter[type]': 'IMAGE',
'filter[publishedAfter]': '2026-01-01',
});
// include[] is an array parameter. Append it once per value.
params.append('include[]', 'children');
const response = await fetch(`https://feedframer.com/api/v1/me?${params}`);
const data = await response.json();
filter[type] accepts IMAGE, VIDEO, CAROUSEL_ALBUM and REELS. REELS returns the same posts as VIDEO, because Instagram reports reels as videos. The mediaType field in responses is only ever IMAGE, VIDEO or CAROUSEL_ALBUM. See Filtering and sorting for the date filters and sort.
On the Free plan, filters apply within your 6 most recent posts. filter[type]=VIDEO returns only the videos among those 6, which may be none.
A small client
This client builds the query string, throws a useful error when the API returns one, and returns the parsed response. The later examples on this page reuse it.
class FeedframerError extends Error {
constructor(status, errors = []) {
const first = errors[0] ?? {};
super(first.detail ?? `Feedframer request failed with HTTP ${status}`);
this.name = 'FeedframerError';
this.status = status;
this.code = first.code ?? null;
this.errors = errors;
}
}
class FeedframerClient {
constructor(apiKey, baseUrl = 'https://feedframer.com') {
this.apiKey = apiKey;
this.baseUrl = baseUrl;
}
async getFeed(params = {}) {
const query = new URLSearchParams({ api_key: this.apiKey });
for (const [name, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
value.forEach((item) => query.append(name, item));
} else {
query.append(name, value);
}
}
const response = await fetch(`${this.baseUrl}/api/v1/me?${query}`);
if (!response.ok) {
let errors = [];
try {
({ errors = [] } = await response.json());
} catch {
// The body wasn't JSON. Keep the HTTP status.
}
throw new FeedframerError(response.status, errors);
}
return response.json();
}
getPostsByType(type, params = {}) {
return this.getFeed({ ...params, 'filter[type]': type });
}
}
Usage:
const client = new FeedframerClient('YOUR_API_KEY');
const feed = await client.getFeed();
console.log(feed.username, feed.posts.length);
const images = await client.getPostsByType('IMAGE');
console.log(images.posts.every((post) => post.mediaType === 'IMAGE')); // true
// Array values become repeated parameters: include[]=children&include[]=comments
const detailed = await client.getFeed({ 'include[]': ['children', 'comments'] });
console.log(detailed.posts[0].children, detailed.posts[0].comments);
Error handling
Every error response has the same JSON:API body, whatever Accept header you send:
{
"errors": [
{
"status": "422",
"code": "VALIDATION_ERROR",
"title": "Invalid Parameter",
"detail": "The page[size] field must not be greater than 100.",
"source": { "parameter": "page[size]" }
}
]
}
The client above turns that body into a FeedframerError. Switch on code:
const client = new FeedframerClient('YOUR_API_KEY');
try {
const feed = await client.getFeed({ 'filter[type]': 'IMAGE' });
console.log(feed.posts.length);
} catch (error) {
if (!(error instanceof FeedframerError)) {
// fetch itself failed: offline, DNS, or the request was blocked.
console.error('Network error:', error.message);
} else {
switch (error.code) {
case 'MISSING_API_KEY':
case 'INVALID_API_KEY':
// The key is wrong, was regenerated, or its account was disconnected.
console.error('Check your API key on the Feedframer Dashboard.');
break;
case 'VALIDATION_ERROR':
// One entry per bad parameter.
for (const e of error.errors) {
console.error(`${e.source?.parameter}: ${e.detail}`);
}
break;
default:
// INTERNAL_SERVER_ERROR and anything unexpected. Retry later.
console.error(`HTTP ${error.status}: ${error.message}`);
}
}
}
The full list of codes is on the Errors page.
Choosing which image to show
Pick the image URL carefully:
- For
VIDEOposts,mediaUrlis usually an.mp4file. An<img>can't show it. UsethumbnailUrlfor the still image, or putmediaUrlin a<video>element. - On Premium, posts carry
sizes: resized copies at 320, 768, 960 and 1200 pixels wide. For videos these are sizes of the thumbnail. On Free thesizesfield is left out. - The keys of
sizesare strings, so writepost.sizes['320'], notpost.sizes.320.
function imageFor(post, width = '768') {
if (post.sizes) {
return post.sizes[width];
}
if (post.mediaType === 'VIDEO') {
return post.thumbnailUrl; // may be null
}
return post.mediaUrl;
}
function srcsetFor(post) {
if (!post.sizes) {
return '';
}
return Object.entries(post.sizes)
.map(([width, url]) => `${url} ${width}w`)
.join(', ');
}
DOM rendering
This renders a grid of linked images. It builds elements with createElement and sets text with textContent, so a caption can never inject HTML into your page.
<div id="instagram-feed" class="instagram-grid"></div>
<style>
.instagram-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.instagram-grid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
</style>
function renderPosts(container, posts) {
for (const post of posts) {
const src = imageFor(post);
if (!src) continue;
const link = document.createElement('a');
link.href = post.permalink;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const img = document.createElement('img');
img.src = src;
img.alt = post.altText ?? post.caption?.slice(0, 120) ?? 'Instagram post';
img.loading = 'lazy';
const srcset = srcsetFor(post);
if (srcset) {
img.setAttribute('srcset', srcset);
img.setAttribute('sizes', '(min-width: 768px) 33vw, 100vw');
}
link.append(img);
if (post.mediaType === 'VIDEO') {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = 'Video';
link.append(badge);
}
container.append(link);
}
}
async function renderInstagramFeed(apiKey, containerId) {
const container = document.getElementById(containerId);
container.textContent = 'Loading Instagram posts...';
try {
const client = new FeedframerClient(apiKey);
const data = await client.getFeed();
container.textContent = '';
if (data.posts.length === 0) {
container.textContent = 'No posts yet.';
return;
}
renderPosts(container, data.posts);
} catch (error) {
container.textContent = `Could not load Instagram posts: ${error.message}`;
}
}
renderInstagramFeed('YOUR_API_KEY', 'instagram-feed');
Pagination
Pagination uses cursors. Each response has a pagination object:
{ "nextCursor": "eyJwdWJsaXNoZWRfYXQiOi...", "prevCursor": null, "hasMore": true, "perPage": 12 }
To get the next page, send nextCursor back unchanged as page[cursor]. Stop when hasMore is false.
Free keys get one page: the 6 most recent posts, with hasMore: false and nextCursor: null. The code below works for both plans. On Free, the "Load more" button hides after the first load. See Pagination for details.
Load more button
function createFeed(client, params = {}) {
let cursor = null;
let hasMore = true;
let loading = false;
return {
get hasMore() {
return hasMore;
},
async next() {
if (!hasMore || loading) return [];
loading = true;
try {
const data = await client.getFeed({ ...params, 'page[cursor]': cursor });
cursor = data.pagination.nextCursor;
hasMore = data.pagination.hasMore;
return data.posts;
} finally {
loading = false;
}
},
};
}
<div id="instagram-feed" class="instagram-grid"></div>
<button id="load-more" type="button">Load more</button>
const client = new FeedframerClient('YOUR_API_KEY');
// 12 per page suits Premium. Free keys always get their 6 latest posts
// and hasMore: false, whatever page size you ask for.
const feed = createFeed(client, { 'page[size]': 12 });
const container = document.getElementById('instagram-feed');
const button = document.getElementById('load-more');
async function loadMore() {
button.disabled = true;
button.textContent = 'Loading...';
try {
renderPosts(container, await feed.next());
} catch (error) {
console.error(error);
}
button.hidden = !feed.hasMore;
button.disabled = false;
button.textContent = 'Load more';
}
button.addEventListener('click', loadMore);
loadMore();
Infinite scroll
Watch an element below the grid with IntersectionObserver. When it comes into view, load the next page. Disconnect the observer once hasMore is false.
<div id="instagram-feed" class="instagram-grid"></div>
<div id="feed-end"></div>
const client = new FeedframerClient('YOUR_API_KEY');
const feed = createFeed(client, { 'page[size]': 12 }); // Free keys: 6 posts, one page
const container = document.getElementById('instagram-feed');
const sentinel = document.getElementById('feed-end');
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
renderPosts(container, await feed.next());
if (!feed.hasMore) {
observer.disconnect();
}
}, { rootMargin: '400px' });
observer.observe(sentinel);
feed.next() ignores calls while a request is in flight, so fast scrolling won't load the same page twice.
Caching
On production, successful API responses are cached at Feedframer's edge for an hour, and that cache is cleared when new posts arrive. You don't need a browser cache for speed. A short cache in the browser can still save a request on every page view.
Key the cache on the API key as well as the parameters. Otherwise two feeds on one site, or a regenerated key, can serve the wrong data.
class CachedFeedframerClient extends FeedframerClient {
constructor(apiKey, { ttl = 15 * 60 * 1000, storage = globalThis.localStorage } = {}) {
super(apiKey);
this.ttl = ttl;
this.storage = storage;
}
cacheKey(params) {
return `feedframer:${this.apiKey}:${JSON.stringify(params)}`;
}
async getFeed(params = {}) {
const key = this.cacheKey(params);
try {
const cached = JSON.parse(this.storage.getItem(key));
if (cached && Date.now() - cached.savedAt < this.ttl) {
return cached.data;
}
} catch {
// Storage unavailable or entry unreadable: fall through to the API.
}
const data = await super.getFeed(params); // throws on errors, so errors aren't cached
try {
this.storage.setItem(key, JSON.stringify({ savedAt: Date.now(), data }));
} catch {
// Storage full or blocked. Carry on without caching.
}
return data;
}
}
const client = new CachedFeedframerClient('YOUR_API_KEY');
const first = await client.getFeed(); // from the API
const second = await client.getFeed(); // from localStorage for the next 15 minutes
Pass storage: sessionStorage to keep the cache for the browser tab only. For an in-memory cache, pass any object with getItem and setItem methods, such as a small wrapper around a Map.
GraphQL
The GraphQL endpoint takes the same API key as a query parameter. The root query is me. Field names differ from the REST API: type instead of mediaType, publishedAt instead of timestamp, and id is Feedframer's own id (instagramPostId is Instagram's).
const FEED_QUERY = `
query Feed($first: Int, $cursor: String, $type: String) {
me(first: $first, cursor: $cursor, type: $type) {
username
posts {
id
instagramPostId
type
caption
mediaUrl
thumbnailUrl
permalink
publishedAt
}
pagination {
nextCursor
hasMore
}
}
}
`;
async function fetchFeedGraphQL(apiKey, variables = {}) {
const response = await fetch(`https://feedframer.com/graphql?api_key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: FEED_QUERY, variables }),
});
const body = await response.json();
// A missing or invalid key is an HTTP 401 with the REST error body.
if (!response.ok) {
throw new FeedframerError(response.status, body.errors ?? []);
}
// Query problems come back as HTTP 200 with a GraphQL errors array.
if (body.errors) {
throw new Error(body.errors.map((e) => e.message).join('; '));
}
return body.data.me;
}
const me = await fetchFeedGraphQL('YOUR_API_KEY', { type: 'IMAGE' });
console.log(me.username, me.posts.length, me.pagination.hasMore);
first defaults to your plan's limit and is capped at it. See the GraphQL API page for every field and argument.
Keeping the key on a server
To keep your key out of the browser, put a small endpoint on your own server that adds the key and forwards the request. This one uses only Node.js built-ins:
// server.mjs
// Run with: FEEDFRAMER_API_KEY=your_key node server.mjs
import http from 'node:http';
const FORWARDED_PARAMS = [
'page[size]',
'page[cursor]',
'filter[type]',
'filter[publishedAfter]',
'filter[publishedBefore]',
'sort',
];
http
.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method !== 'GET' || url.pathname !== '/instagram') {
res.writeHead(404).end();
return;
}
const upstream = new URL('https://feedframer.com/api/v1/me');
upstream.searchParams.set('api_key', process.env.FEEDFRAMER_API_KEY);
for (const name of FORWARDED_PARAMS) {
const value = url.searchParams.get(name);
if (value !== null) upstream.searchParams.set(name, value);
}
try {
const response = await fetch(upstream);
res.writeHead(response.status, {
'Content-Type': response.headers.get('content-type') ?? 'application/json',
});
res.end(await response.text());
} catch {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ errors: [{ status: '502', detail: 'Could not reach Feedframer.' }] }));
}
})
.listen(3000);
Your page then calls /instagram instead of Feedframer, without an api_key. The response body is the same, so renderPosts and the pagination code work unchanged:
const response = await fetch('/instagram?filter[type]=IMAGE');
const data = await response.json();
renderPosts(document.getElementById('instagram-feed'), data.posts);
TypeScript
These types match the /api/v1/me response.
export type MediaType = 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
export type Sizes = Record<'320' | '768' | '960' | '1200', string>;
export interface Media {
id: string; // Instagram's post id
caption: string | null;
altText: string | null;
mediaType: MediaType;
mediaUrl: string; // an .mp4 for most VIDEO posts
thumbnailUrl: string | null; // set for VIDEO posts
permalink: string;
timestamp: string; // ISO 8601
likeCount: number | null;
commentsCount: number | null;
sizes?: Sizes | null; // Premium only; omitted otherwise
}
export interface Comment {
id: string;
text: string;
timestamp: string;
like_count: number;
}
export interface Post extends Media {
children?: Media[]; // with include[]=children
comments?: Comment[]; // with include[]=comments
}
export interface Pagination {
nextCursor: string | null;
prevCursor: string | null;
hasMore: boolean;
perPage: number;
}
export interface FeedResponse {
username: string;
name: string | null;
biography: string | null;
website: string | null;
profilePictureUrl: string | null;
followersCount: number | null;
followsCount: number | null;
mediaCount: number | null;
posts: Post[];
pagination: Pagination;
}
export interface ApiError {
status: string;
code: string;
title: string;
detail: string;
source?: { parameter: string };
}
export async function getFeed(
apiKey: string,
params: Record<string, string> = {},
): Promise<FeedResponse> {
const query = new URLSearchParams({ api_key: apiKey, ...params });
const response = await fetch(`https://feedframer.com/api/v1/me?${query}`);
if (!response.ok) {
const body: { errors?: ApiError[] } = await response.json().catch(() => ({}));
const first = body.errors?.[0];
throw new Error(first ? `${first.code}: ${first.detail}` : `HTTP ${response.status}`);
}
return response.json() as Promise<FeedResponse>;
}