React Instagram Feed Tutorial
Build Instagram feeds in React with Feedframer's API: a basic component, a reusable hook with filtering and "load more", error handling, and a Next.js server component.
Overview
This guide builds a reusable Instagram feed for React using hooks. Every example calls GET /api/v1/me, which returns your Instagram profile and your posts in one response. The REST API reference lists every parameter.
Prerequisites
- React 16.8 or later (hooks)
- A Feedframer account with a connected Instagram account
- The account's API key, shown on its card on the Dashboard
About your API key
A component that runs in the browser sends your API key with each request, so visitors can see it in the network tab. Putting the key in an environment variable such as VITE_FEEDFRAMER_API_KEY or NEXT_PUBLIC_FEEDFRAMER_API_KEY keeps it out of your repository, but the build still copies it into the JavaScript bundle.
The key is read-only and only reads posts that are already public on Instagram, so this is usually fine. 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.
To keep the key private, fetch on the server instead. The Next.js example below does that.
Quick start
1. Install dependencies
There's nothing to install. The examples use the browser's fetch.
2. Basic component
import { useEffect, useState } from 'react';
const API_KEY = 'YOUR_API_KEY';
// VIDEO posts: mediaUrl is usually an .mp4, which an <img> can't show.
// Premium posts carry resized copies in `sizes`.
function imageFor(post) {
if (post.sizes) return post.sizes['768'];
return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}
function altFor(post) {
return post.altText || post.caption?.slice(0, 120) || 'Instagram post';
}
export default function InstagramFeed() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(
`https://feedframer.com/api/v1/me?api_key=${API_KEY}`,
{ signal: controller.signal }
);
const body = await response.json();
if (!response.ok) {
throw new Error(body.errors?.[0]?.detail ?? `HTTP ${response.status}`);
}
setPosts(body.posts);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
load();
return () => controller.abort();
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Could not load Instagram posts: {error}</div>;
return (
<div className="grid grid-cols-3 gap-4">
{posts.map((post) => {
const src = imageFor(post);
if (!src) return null;
return (
<a key={post.id} href={post.permalink} target="_blank" rel="noopener noreferrer">
<img
src={src}
alt={altFor(post)}
loading="lazy"
className="aspect-square w-full rounded object-cover"
/>
</a>
);
})}
</div>
);
}
Without page[size], the API returns your plan's limit: the 6 most recent posts on Free, or up to 100 on Premium.
Custom hook pattern
This hook handles the request, filtering by type, and "load more" with cursors. It returns the profile fields as well as the posts.
// hooks/useFeedframerPosts.js
import { useCallback, useEffect, useRef, useState } from 'react';
const BASE_URL = 'https://feedframer.com';
export 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;
}
}
export async function fetchFeed(apiKey, params = {}) {
const query = new URLSearchParams({ api_key: apiKey });
for (const [name, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
query.set(name, value);
}
}
const response = await fetch(`${BASE_URL}/api/v1/me?${query}`);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new FeedframerError(response.status, body.errors);
}
return body;
}
export function useFeedframerPosts(apiKey, { type, pageSize } = {}) {
const [state, setState] = useState({
profile: null,
posts: [],
cursor: null,
hasMore: false,
loading: true,
error: null,
});
const latestRequest = useRef(0);
const load = useCallback(
async (cursor, append) => {
const requestId = ++latestRequest.current;
setState((s) => ({ ...s, loading: true, error: null }));
try {
const { posts, pagination, ...profile } = await fetchFeed(apiKey, {
'filter[type]': type,
'page[size]': pageSize,
'page[cursor]': cursor,
});
// Ignore responses for a key or filter that has since changed.
if (requestId !== latestRequest.current) return;
setState((s) => ({
profile,
posts: append ? [...s.posts, ...posts] : posts,
cursor: pagination.nextCursor,
hasMore: pagination.hasMore,
loading: false,
error: null,
}));
} catch (error) {
if (requestId !== latestRequest.current) return;
// A failed first page shouldn't leave the previous feed on screen.
setState((s) => ({
...s,
posts: append ? s.posts : [],
hasMore: append ? s.hasMore : false,
loading: false,
error,
}));
}
},
[apiKey, type, pageSize]
);
// First page, and again whenever the key or filter changes.
useEffect(() => {
load(null, false);
}, [load]);
const loadMore = useCallback(() => {
if (state.hasMore && !state.loading) {
load(state.cursor, true);
}
}, [load, state.hasMore, state.loading, state.cursor]);
return { ...state, loadMore };
}
error is a FeedframerError with status and code (for example INVALID_API_KEY), or a plain Error if the network request failed.
Usage
import { useFeedframerPosts } from './hooks/useFeedframerPosts';
function LatestImages() {
const { profile, posts, loading, error } = useFeedframerPosts('YOUR_API_KEY', {
type: 'IMAGE',
});
if (loading && posts.length === 0) return <p>Loading...</p>;
if (error) return <p>Could not load posts: {error.message}</p>;
return (
<section>
<h2>@{profile.username}</h2>
<p>{posts.length} image posts</p>
</section>
);
}
type accepts IMAGE, VIDEO, CAROUSEL_ALBUM or REELS. REELS returns the same posts as VIDEO, because Instagram reports reels as videos. On the Free plan, the filter applies within your 6 most recent posts, so it can return fewer than 6, or none.
Advanced component with filtering
This gallery has filter buttons, a hover caption, a video badge and a "Load more" button. React escapes {post.caption} as text, so captions can't inject HTML.
import { useState } from 'react';
import { useFeedframerPosts } from './hooks/useFeedframerPosts';
const FILTERS = [
{ label: 'All', type: '' },
{ label: 'Images', type: 'IMAGE' },
{ label: 'Videos', type: 'VIDEO' },
{ label: 'Carousels', type: 'CAROUSEL_ALBUM' },
];
function imageFor(post) {
if (post.sizes) return post.sizes['768'];
return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}
function srcSetFor(post) {
if (!post.sizes) return undefined;
return Object.entries(post.sizes)
.map(([width, url]) => `${url} ${width}w`)
.join(', ');
}
function PostTile({ post }) {
const src = imageFor(post);
if (!src) return null;
return (
<a
href={post.permalink}
target="_blank"
rel="noopener noreferrer"
className="group relative block aspect-square overflow-hidden rounded-lg"
>
<img
src={src}
srcSet={srcSetFor(post)}
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 100vw"
alt={post.altText || post.caption?.slice(0, 120) || 'Instagram post'}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{post.mediaType === 'VIDEO' && (
<span className="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-xs text-white">
Video
</span>
)}
{post.caption && (
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/50">
<p className="line-clamp-3 px-4 text-center text-white opacity-0 group-hover:opacity-100">
{post.caption}
</p>
</div>
)}
</a>
);
}
export default function InstagramGallery({ apiKey }) {
const [type, setType] = useState('');
// 12 per page suits Premium. Free keys always get their 6 latest posts, one page.
const { posts, loading, error, hasMore, loadMore } = useFeedframerPosts(apiKey, {
type,
pageSize: 12,
});
return (
<div>
<div className="mb-6 flex gap-2">
{FILTERS.map((filter) => (
<button
key={filter.label}
type="button"
onClick={() => setType(filter.type)}
className={`rounded px-4 py-2 ${type === filter.type ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
>
{filter.label}
</button>
))}
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-red-700">
Could not load Instagram posts: {error.message}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
{posts.map((post) => (
<PostTile key={post.id} post={post} />
))}
</div>
{!loading && !error && posts.length === 0 && <p>No posts match this filter.</p>}
{loading && <p className="mt-6 text-center">Loading...</p>}
{hasMore && !loading && (
<button type="button" onClick={loadMore} className="mt-6 rounded bg-gray-900 px-4 py-2 text-white">
Load more
</button>
)}
</div>
);
}
hasMore is always false for Free keys, so the "Load more" button never shows for them. See Pagination for how cursors work.
Playing videos
To play a video inline instead of showing its thumbnail, use a <video> element with mediaUrl as the source and thumbnailUrl as the poster:
function PostMedia({ post }) {
if (post.mediaType === 'VIDEO') {
return (
<video
src={post.mediaUrl}
poster={post.thumbnailUrl ?? undefined}
controls
playsInline
preload="none"
className="w-full"
/>
);
}
return (
<img
src={post.sizes ? post.sizes['1200'] : post.mediaUrl}
alt={post.altText || 'Instagram post'}
className="w-full"
/>
);
}
Next.js integration
In the Next.js App Router, a server component fetches on the server, so the API key never reaches the browser. Store it in a server-only variable (no NEXT_PUBLIC_ prefix):
# .env.local
FEEDFRAMER_API_KEY=your_api_key_here
// app/instagram/page.tsx
import type { FeedResponse } from '@/types/feedframer';
async function getFeed(): Promise<FeedResponse> {
const response = await fetch(
`https://feedframer.com/api/v1/me?api_key=${process.env.FEEDFRAMER_API_KEY}`,
{ next: { revalidate: 3600 } } // refetch at most once an hour
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.errors?.[0]?.detail ?? `Feedframer returned HTTP ${response.status}`);
}
return response.json();
}
export default async function InstagramPage() {
const feed = await getFeed();
return (
<div className="grid grid-cols-3 gap-4">
{feed.posts.map((post) => {
const src = post.sizes?.['768'] ?? (post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl);
if (!src) return null;
return (
<a key={post.id} href={post.permalink} target="_blank" rel="noopener noreferrer">
<img src={src} alt={post.altText || 'Instagram post'} loading="lazy" />
</a>
);
})}
</div>
);
}
The FeedResponse type is in the TypeScript section below. For more patterns, including API routes, see the Next.js example.
Error handling
Every error response from the API has the same JSON:API body, with a code on each error:
{
"errors": [
{
"status": "401",
"code": "INVALID_API_KEY",
"title": "Authentication Failed",
"detail": "The provided API key is invalid."
}
]
}
The hook turns that into a FeedframerError. Switch on error.code to show visitors a useful message:
import { FeedframerError, useFeedframerPosts } from './hooks/useFeedframerPosts';
function errorMessage(error) {
if (!(error instanceof FeedframerError)) {
// fetch itself failed: offline, DNS, or the request was blocked.
return 'Could not load Instagram posts. Check your connection.';
}
switch (error.code) {
case 'MISSING_API_KEY':
case 'INVALID_API_KEY':
// The key is wrong, was regenerated, or its account was disconnected.
return 'This Instagram feed is not set up correctly.';
case 'VALIDATION_ERROR':
return `Invalid request: ${error.message}`;
default:
// INTERNAL_SERVER_ERROR, or a response without an error body.
return 'Instagram posts are unavailable right now. Please try again later.';
}
}
export default function InstagramFeed({ apiKey }) {
const { posts, loading, error } = useFeedframerPosts(apiKey);
if (loading && posts.length === 0) return <p>Loading...</p>;
if (error) return <p role="alert">{errorMessage(error)}</p>;
if (posts.length === 0) return <p>No Instagram posts yet.</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={post.permalink}>{post.caption ?? 'View on Instagram'}</a>
</li>
))}
</ul>
);
}
The Errors page lists every code.
Performance
- Lazy-load images with
loading="lazy", as the examples above do. - Use
sizeson Premium. The four widths (320, 768, 960 and 1200 pixels) let the browser pick a small file on small screens.srcSetForabove builds thesrcSetattribute. Addformat=webpto the request to get WebP URLs insizesinstead of JPEG. - Don't add a cache-busting parameter. Successful responses are cached at Feedframer's edge for an hour, keyed on the full URL, and that cache is cleared when new posts arrive. A timestamp in the URL skips the cache every time.
Testing
This test uses React Testing Library with Jest. It mocks fetch with a response shaped like the real API and checks the basic component from the quick start.
import { render, screen } from '@testing-library/react';
import InstagramFeed from './InstagramFeed';
const feed = {
username: 'your_username',
name: 'Your Name',
biography: null,
website: null,
profilePictureUrl: null,
followersCount: 1234,
followsCount: 567,
mediaCount: 89,
posts: [
{
id: '18123456789012345',
caption: 'Sunset over the harbour',
altText: null,
mediaType: 'IMAGE',
mediaUrl: 'https://cdn.feedframer.com/your_username/18123456789012345_media.jpg',
thumbnailUrl: null,
permalink: 'https://www.instagram.com/p/ABC123def45/',
timestamp: '2026-01-15T18:30:00+00:00',
likeCount: 42,
commentsCount: 3,
},
],
pagination: { nextCursor: null, prevCursor: null, hasMore: false, perPage: 6 },
};
function mockFetch(status, body) {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body) })
);
}
test('renders Instagram posts', async () => {
mockFetch(200, feed);
render(<InstagramFeed />);
const image = await screen.findByAltText('Sunset over the harbour');
expect(image.getAttribute('src')).toBe(feed.posts[0].mediaUrl);
});
test('shows the API error detail', async () => {
mockFetch(401, {
errors: [
{
status: '401',
code: 'INVALID_API_KEY',
title: 'Authentication Failed',
detail: 'The provided API key is invalid.',
},
],
});
render(<InstagramFeed />);
expect(await screen.findByText(/The provided API key is invalid/)).toBeTruthy();
});
Environment variables
Keep the key out of source control with an environment variable. The name depends on your build tool:
# Vite (.env.local)
VITE_FEEDFRAMER_API_KEY=your_api_key_here
# Next.js client components (.env.local)
NEXT_PUBLIC_FEEDFRAMER_API_KEY=your_api_key_here
// Vite
const apiKey = import.meta.env.VITE_FEEDFRAMER_API_KEY;
// Next.js client component
const nextApiKey = process.env.NEXT_PUBLIC_FEEDFRAMER_API_KEY;
Both prefixes tell the build tool to copy the value into the browser bundle. That's what makes it available to client code, and it's also why visitors can see it. For a key that stays on the server, use a Next.js server component as shown above.
Best practices
- Keep the key out of your repository with an environment variable, and use Regenerate if it leaks.
- Show
thumbnailUrlfor video posts in an<img>, or use a<video>element formediaUrl. - Handle errors by switching on
error.code. - Check
hasMorebefore offering another page. Free keys only ever get one page. - Add loading states so visitors know something is happening.
- Use TypeScript with the types below.
TypeScript support
These types match the /api/v1/me response:
// types/feedframer.ts
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;
}