Next.js Integration
Introduction
This guide shows you how to integrate Feedframer into your Next.js application using both the App Router (Next.js 13+) and Pages Router approaches.
Installation & Setup
Step 1: Install Dependencies
No additional packages required! Next.js includes fetch natively.
Step 2: Add Environment Variables
Create .env.local:
# For server-side usage (Server Components, API Routes)
FEEDFRAMER_API_KEY=your_api_key_here
# For client-side usage (optional - safe for Feedframer!)
NEXT_PUBLIC_FEEDFRAMER_API_KEY=your_api_key_here
Note: Feedframer API keys are safe to expose in the browser since the API only serves publicly-available Instagram data. Using
NEXT_PUBLIC_prefix is perfectly fine for client-side fetching!
App Router (Next.js 13+)
Server Component
Fetch data directly in a server component (recommended for SEO and performance):
// app/instagram/page.tsx
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
interface FeedframerResponse {
username: string;
posts: Post[];
pagination: {
nextCursor: string | null;
hasMore: boolean;
perPage: number;
};
}
async function getPosts(): Promise<Post[]> {
const apiKey = process.env.FEEDFRAMER_API_KEY;
const res = await fetch(
`https://feedframer.com/api/v1/me?api_key=${apiKey}&page[size]=12`,
{
next: { revalidate: 3600 } // Cache for 1 hour
}
);
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
const data: FeedframerResponse = await res.json();
return data.posts;
}
export default async function InstagramPage() {
const posts = await getPosts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Instagram Feed</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{posts.map((post) => (
<div key={post.id} className="bg-white rounded-lg shadow overflow-hidden">
<img
src={post.mediaUrl}
alt={post.caption || 'Instagram post'}
className="w-full h-64 object-cover"
/>
<div className="p-4">
<p className="text-sm text-gray-700 line-clamp-3">
{post.caption}
</p>
{/* Engagement Stats */}
<div className="flex items-center gap-4 mt-3 text-sm text-gray-600">
{post.likeCount !== null && (
<span className="flex items-center gap-1">
❤️ {post.likeCount.toLocaleString()}
</span>
)}
{post.commentsCount !== null && (
<span className="flex items-center gap-1">
💬 {post.commentsCount.toLocaleString()}
</span>
)}
</div>
<a
href={post.permalink}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block text-blue-600 hover:underline"
>
View on Instagram →
</a>
</div>
</div>
))}
</div>
</div>
);
}
Client Component with useState
For interactive features:
// app/instagram/client-feed.tsx
'use client';
import { useState, useEffect } from 'react';
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchPosts() {
try {
const res = await fetch('/api/instagram/posts');
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
const data = await res.json();
setPosts(data.posts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
fetchPosts();
}, []);
if (loading) {
return <div>Loading Instagram posts...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div className="grid grid-cols-3 gap-4">
{posts.map((post) => (
<div key={post.id}>
<img src={post.mediaUrl} alt="" className="w-full" />
</div>
))}
</div>
);
}
API Route (App Router)
Create an API route for server-side caching and data transformation:
// app/api/instagram/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type');
const size = searchParams.get('size') || '12';
const apiKey = process.env.FEEDFRAMER_API_KEY;
const params = new URLSearchParams({
api_key: apiKey!,
'page[size]': size,
});
if (type) {
params.append('filter[type]', type);
}
try {
const res = await fetch(
`https://feedframer.com/api/v1/me?${params.toString()}`,
{
next: { revalidate: 3600 } // Cache for 1 hour
}
);
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json({
posts: data.posts,
pagination: data.pagination,
});
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Pages Router (Next.js 12 and below)
getServerSideProps
Fetch data on each request:
// pages/instagram.tsx
import { GetServerSideProps } from 'next';
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
export default function InstagramPage({ posts }: Props) {
return (
<div>
<h1>Instagram Feed</h1>
<div className="grid grid-cols-3 gap-4">
{posts.map((post) => (
<div key={post.id}>
<img src={post.mediaUrl} alt="" />
<p>{post.caption}</p>
</div>
))}
</div>
</div>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const apiKey = process.env.FEEDFRAMER_API_KEY;
const res = await fetch(
`https://feedframer.com/api/v1/me?api_key=${apiKey}&page[size]=12`
);
if (!res.ok) {
return {
props: {
posts: [],
},
};
}
const data = await res.json();
return {
props: {
posts: data.posts,
},
};
};
getStaticProps
Fetch data at build time with revalidation:
// pages/instagram.tsx
import { GetStaticProps } from 'next';
export const getStaticProps: GetStaticProps = async () => {
const apiKey = process.env.FEEDFRAMER_API_KEY;
const res = await fetch(
`https://feedframer.com/api/v1/me?api_key=${apiKey}&page[size]=12`
);
const data = await res.json();
return {
props: {
posts: data.posts,
},
revalidate: 3600, // Revalidate every hour
};
};
API Route (Pages Router)
// pages/api/instagram/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const apiKey = process.env.FEEDFRAMER_API_KEY;
const { type, size = '12' } = req.query;
const params = new URLSearchParams({
api_key: apiKey!,
'page[size]': size as string,
});
if (type) {
params.append('filter[type]', type as string);
}
try {
const response = await fetch(
`https://feedframer.com/api/v1/me?${params.toString()}`
);
if (!response.ok) {
return res.status(response.status).json({
error: 'Failed to fetch posts',
});
}
const data = await response.json();
return res.status(200).json({
posts: data.posts,
pagination: data.pagination,
});
} catch (error) {
return res.status(500).json({
error: 'Internal server error',
});
}
}
Custom Hook
Create a reusable hook for client-side fetching:
// hooks/useFeedframer.ts
import { useState, useEffect } from 'react';
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
}
export function useFeedframer(options: UseFeedframerOptions = {}) {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchPosts() {
try {
const params = new URLSearchParams();
if (options.type) {
params.append('type', options.type);
}
if (options.size) {
params.append('size', options.size.toString());
}
const res = await fetch(`/api/instagram/posts?${params.toString()}`);
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
const data = await res.json();
setPosts(data.posts);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
fetchPosts();
}, [options.type, options.size]);
return { posts, loading, error };
}
Usage:
'use client';
import { useFeedframer } from '@/hooks/useFeedframer';
export default function InstagramWidget() {
const { posts, loading, error } = useFeedframer({
type: 'IMAGE',
size: 6,
});
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="grid grid-cols-2 gap-2">
{posts.map((post) => (
<img key={post.id} src={post.mediaUrl} alt="" />
))}
</div>
);
}
GraphQL Integration
Fetching with GraphQL
async function getPostsGraphQL() {
const apiKey = process.env.FEEDFRAMER_API_KEY;
const query = `
query {
posts(first: 12, type: "IMAGE") {
data {
id
caption
mediaUrl
publishedAt
likeCount
commentsCount
instagramAccount {
username
}
}
}
}
`;
const res = await fetch(
`https://feedframer.com/graphql?api_key=${apiKey}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
next: { revalidate: 3600 }
}
);
const data = await res.json();
return data.posts.posts.data;
}
Filtering
Filter by Type
// Fetch only images
const res = await fetch('/api/instagram/posts?type=IMAGE&size=12');
// Fetch only videos
const res = await fetch('/api/instagram/posts?type=VIDEO&size=12');
Filter by Date Range
const params = new URLSearchParams({
api_key: apiKey!,
'filter[publishedAfter]': '2024-01-01T00:00:00Z',
'filter[publishedBefore]': '2024-12-31T23:59:59Z',
'page[size]': '50',
});
const res = await fetch(`https://feedframer.com/api/v1/me?${params}`);
Pagination
Infinite Scroll
'use client';
import { useState } from 'react';
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
const res = await fetch(`/api/instagram/posts?${params}`);
const data = await res.json();
setPosts((prev) => [...prev, ...data.posts]);
setCursor(data.pagination.nextCursor);
setLoading(false);
}
return (
<div>
<div className="grid grid-cols-3 gap-4">
{posts.map((post) => (
<div key={post.id}>
<img src={post.mediaUrl} alt="" />
</div>
))}
</div>
{cursor && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Caching Strategies
Static Generation
Best for content that doesn't change often:
// App Router
async function getPosts() {
const res = await fetch(url, {
next: { revalidate: 3600 } // Revalidate hourly
});
return res.json();
}
API Route Caching
Cache API responses:
// Simple in-memory cache (production: use Redis)
const cache = new Map();
export async function GET(request: Request) {
const cacheKey = 'instagram_posts';
if (cache.has(cacheKey)) {
const { data, timestamp } = cache.get(cacheKey);
// Return cached data if less than 1 hour old
if (Date.now() - timestamp < 3600000) {
return NextResponse.json(data);
}
}
// Fetch fresh data
const res = await fetch(url);
const data = await res.json();
cache.set(cacheKey, { data, timestamp: Date.now() });
return NextResponse.json(data);
}
Error Handling
Comprehensive Error Handling
async function getPosts() {
try {
const res = await fetch(url, {
next: { revalidate: 3600 }
});
if (!res.ok) {
const error = await res.json();
if (error.errors?.[0]?.code === 'RATE_LIMIT_EXCEEDED') {
console.warn('Rate limit exceeded');
// Return cached data or empty array
return [];
}
throw new Error('Failed to fetch posts');
}
const data = await res.json();
return data.posts;
} catch (error) {
console.error('Error fetching Instagram posts:', error);
return [];
}
}
TypeScript Types
Define types for better type safety:
// types/feedframer.ts
export interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
export interface FeedframerResponse {
username: string;
posts: Post[];
pagination: {
nextCursor: string | null;
hasMore: boolean;
perPage: number;
};
}
export interface FeedframerError {
errors: Array<{
status: string;
code: string;
title: string;
detail: string;
}>;
}
Best Practices
- Choose Your Approach - Client-side (simpler, direct API calls) or Server-side (better for caching)
- Implement Caching - Cache for at least 1 hour (ISR, API routes, or React Query)
- Handle Errors Gracefully - Always provide fallback content
- Optimize Images - Use Next.js Image component for optimization
- Type Safety - Use TypeScript for better developer experience
- Client-Side is OK! - Feedframer keys are safe to expose since data is public
Complete Example
Full working example with App Router:
// app/instagram/page.tsx
import { Suspense } from 'react';
import InstagramFeed from '@/components/InstagramFeed';
export default function InstagramPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Instagram Feed</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<InstagramFeed />
</Suspense>
</div>
);
}
// components/InstagramFeed.tsx
import Image from 'next/image';
interface Post {
id: string;
caption: string | null;
mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
mediaUrl: string;
thumbnailUrl: string | null;
permalink: string;
timestamp: string;
likeCount: number | null;
commentsCount: number | null;
}
const data = await res.json();
return data.posts;
}
export default async function InstagramFeed() {
const posts = await getPosts();
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{posts.map((post) => (
<a
key={post.id}
href={post.permalink}
target="_blank"
rel="noopener noreferrer"
className="group"
>
<div className="relative aspect-square overflow-hidden rounded-lg">
<Image
src={post.mediaUrl}
alt={post.caption || 'Instagram post'}
fill
className="object-cover transition group-hover:scale-105"
/>
</div>
<p className="mt-2 text-sm text-gray-600 line-clamp-2">
{post.caption}
</p>
{/* Engagement Stats */}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-500">
{post.likeCount !== null && (
<span className="flex items-center gap-1">
❤️ {post.likeCount.toLocaleString()}
</span>
)}
{post.commentsCount !== null && (
<span className="flex items-center gap-1">
💬 {post.commentsCount.toLocaleString()}
</span>
)}
</div>
</a>
))}
</div>
);
}