Next.js Integration

Introduction

This guide shows how to display your Instagram posts in a Next.js app with Feedframer. It covers the App Router and the Pages Router. Both use one small helper module that calls the REST API, so you only write the request and error handling once.

What you get back depends on your plan:

  • Free keys return your 6 most recent posts. There are no further pages.
  • Premium keys return up to 100 posts per request and can page through everything stored using cursors. Premium responses also include sizes, a set of resized image URLs.

The examples below work with both plans. They don't set page[size], so each request gets your plan's limit.

Setup

Step 1: Add your API key

Copy your key from the account card on your Dashboard (labelled "Feedframer API Token") and add it to .env.local:

FEEDFRAMER_API_KEY=YOUR_API_KEY

The examples read the key on the server only. The key can only read your own account's public Instagram data, but anyone who has it can use it, so don't prefix it with NEXT_PUBLIC_. Browser code in this guide talks to your own route handler instead, which adds the key on the server.

If a key leaks, press Regenerate on the Dashboard. The old key stops working straight away.

Step 2: Allow Feedframer images in next/image

Feedframer serves stored media from cdn.feedframer.com. If a file wasn't stored, mediaUrl falls back to Instagram's own CDN (scontent.cdninstagram.com), and those URLs expire after a while. Allow both hosts:

// next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.feedframer.com' },
      { protocol: 'https', hostname: 'scontent.cdninstagram.com' },
    ],
  },
};

export default nextConfig;

Step 3: Add the Feedframer helper

This module holds the response types, a getFeed() function that calls GET /api/v1/me, and an imageFor() function that picks an image URL that's safe to put in an <img>.

// lib/feedframer.ts

const BASE_URL = 'https://feedframer.com';

export type MediaType = 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';

// Values accepted by filter[type]. REELS returns the same posts as VIDEO,
// because Instagram reports reels as VIDEO.
export const TYPE_FILTERS = ['IMAGE', 'VIDEO', 'CAROUSEL_ALBUM', 'REELS'] as const;
export type TypeFilter = (typeof TYPE_FILTERS)[number];

export type ImageWidth = '320' | '768' | '960' | '1200';

export interface Post {
  id: string; // Instagram's post id
  caption: string | null;
  altText: string | null;
  mediaType: MediaType;
  mediaUrl: string; // for VIDEO this is usually an .mp4
  thumbnailUrl: string | null; // set for VIDEO posts
  permalink: string;
  timestamp: string;
  likeCount: number | null;
  commentsCount: number | null;
  sizes?: Record<ImageWidth, string>; // premium only; omitted otherwise
}

export interface Pagination {
  nextCursor: string | null;
  prevCursor: string | null;
  hasMore: boolean;
  perPage: number;
}

export interface FeedframerResponse {
  username: string;
  name: string | null;
  biography: string | null;
  website: string | null;
  profilePictureUrl: string | null;
  followersCount: number;
  followsCount: number;
  mediaCount: number;
  posts: Post[];
  pagination: Pagination;
}

export interface FeedframerErrorBody {
  errors: Array<{
    status: string;
    code: string;
    title: string;
    detail: string;
    source?: { parameter: string };
  }>;
}

export class FeedframerError extends Error {
  status: number;
  code: string;

  constructor(status: number, code: string, message: string) {
    super(message);
    this.name = 'FeedframerError';
    this.status = status;
    this.code = code;
  }
}

export interface FeedOptions {
  type?: TypeFilter;
  cursor?: string | null;
  pageSize?: number; // leave unset to get your plan's limit
  publishedAfter?: string; // ISO 8601 date or datetime
  publishedBefore?: string;
}

export async function getFeed(options: FeedOptions = {}): Promise<FeedframerResponse> {
  const apiKey = process.env.FEEDFRAMER_API_KEY;

  if (!apiKey) {
    throw new Error('FEEDFRAMER_API_KEY is not set');
  }

  const params = new URLSearchParams({ api_key: apiKey });

  if (options.type) params.set('filter[type]', options.type);
  if (options.cursor) params.set('page[cursor]', options.cursor);
  if (options.pageSize) params.set('page[size]', String(options.pageSize));
  if (options.publishedAfter) params.set('filter[publishedAfter]', options.publishedAfter);
  if (options.publishedBefore) params.set('filter[publishedBefore]', options.publishedBefore);

  const res = await fetch(`${BASE_URL}/api/v1/me?${params}`, {
    next: { revalidate: 3600 },
  });

  if (!res.ok) {
    const body = (await res.json().catch(() => null)) as FeedframerErrorBody | null;
    const error = body?.errors?.[0];

    throw new FeedframerError(
      res.status,
      error?.code ?? 'UNKNOWN_ERROR',
      error?.detail ?? `Feedframer returned HTTP ${res.status}`,
    );
  }

  return (await res.json()) as FeedframerResponse;
}

// Returns an image URL for any post type, or null if there isn't one.
// Never use mediaUrl of a VIDEO post in an <img>: it is usually an .mp4.
export function imageFor(post: Post, width: ImageWidth = '768'): string | null {
  if (post.sizes) return post.sizes[width]; // premium; for videos these are thumbnail sizes
  if (post.mediaType === 'VIDEO') return post.thumbnailUrl;
  return post.mediaUrl;
}

getFeed() checks res.ok and turns Feedframer's error body into a FeedframerError with the error code, so callers can decide what to show. The next: { revalidate: 3600 } option tells Next.js to cache the response for an hour. The Caching section below explains why an hour is enough.

The module reads the API key only when getFeed() runs, so client components can import the types and imageFor() from it without the key reaching the browser.

App Router

Server Component

A server component can call getFeed() directly. This is the simplest option and is good for SEO.

// app/instagram/page.tsx

import Image from 'next/image';
import { getFeed, imageFor, type FeedframerResponse } from '@/lib/feedframer';

export const revalidate = 3600;

export default async function InstagramPage() {
  let feed: FeedframerResponse;

  try {
    feed = await getFeed();
  } catch (error) {
    console.error('Feedframer request failed', error);
    return <p>Instagram posts are unavailable right now.</p>;
  }

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">@{feed.username} on Instagram</h1>

      <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
        {feed.posts.map((post) => {
          const src = imageFor(post);

          return (
            <a
              key={post.id}
              href={post.permalink}
              target="_blank"
              rel="noopener noreferrer"
              className="group block"
            >
              <div className="relative aspect-square overflow-hidden rounded-lg bg-gray-100">
                {src && (
                  <Image
                    src={src}
                    alt={post.altText ?? post.caption ?? 'Instagram post'}
                    fill
                    sizes="(min-width: 768px) 33vw, 50vw"
                    className="object-cover transition group-hover:scale-105"
                  />
                )}
                {post.mediaType === 'VIDEO' && (
                  <span className="absolute top-2 right-2 rounded bg-black/60 px-2 py-0.5 text-xs text-white">
                    Video
                  </span>
                )}
              </div>

              {post.caption && (
                <p className="mt-2 text-sm text-gray-600 line-clamp-2">{post.caption}</p>
              )}

              <p className="mt-1 text-xs text-gray-500">
                {post.likeCount !== null && <span>{post.likeCount.toLocaleString()} likes</span>}
                {post.likeCount !== null && post.commentsCount !== null && ' · '}
                {post.commentsCount !== null && (
                  <span>{post.commentsCount.toLocaleString()} comments</span>
                )}
              </p>
            </a>
          );
        })}
      </div>
    </main>
  );
}

likeCount and commentsCount can be null, so the example checks before formatting them.

Route handler (proxy for client components)

Client components shouldn't hold your API key. Give them a route handler that adds the key, validates the type parameter and forwards the cursor:

// app/api/instagram/posts/route.ts

import { FeedframerError, getFeed, TYPE_FILTERS, type TypeFilter } from '@/lib/feedframer';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const type = searchParams.get('type')?.toUpperCase() || undefined;
  const cursor = searchParams.get('cursor');

  if (type && !TYPE_FILTERS.includes(type as TypeFilter)) {
    return Response.json(
      { error: `type must be one of ${TYPE_FILTERS.join(', ')}` },
      { status: 400 },
    );
  }

  try {
    const feed = await getFeed({ type: type as TypeFilter | undefined, cursor });

    return Response.json({ posts: feed.posts, pagination: feed.pagination });
  } catch (error) {
    console.error('Feedframer request failed', error);

    const code = error instanceof FeedframerError ? error.code : 'NETWORK_ERROR';

    return Response.json({ error: 'Could not load Instagram posts', code }, { status: 502 });
  }
}

The handler returns 502 for any upstream problem rather than passing Feedframer's 401 straight through, because a bad key is a server configuration problem, not something the visitor did. The error code is still included so you can see it in the browser's network tab.

Calls look like this:

GET /api/instagram/posts
GET /api/instagram/posts?type=video
GET /api/instagram/posts?cursor=eyJwdWJsaXNoZWRfYXQiOi...

Infinite scroll

Render the first page on the server, then let a client component load more through the route handler as the visitor scrolls. It passes pagination.nextCursor back as cursor until hasMore is false.

On a free key the first response already has hasMore: false, so the component shows the 6 posts and never asks for more.

// app/instagram/feed/page.tsx

import { getFeed } from '@/lib/feedframer';
import InfiniteFeed from '@/components/InfiniteFeed';

export const revalidate = 3600;

export default async function InstagramFeedPage() {
  const feed = await getFeed();

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Instagram</h1>
      <InfiniteFeed
        initialPosts={feed.posts}
        initialCursor={feed.pagination.hasMore ? feed.pagination.nextCursor : null}
      />
    </main>
  );
}
// components/InfiniteFeed.tsx

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import { imageFor, type Pagination, type Post } from '@/lib/feedframer';

interface Props {
  initialPosts: Post[];
  initialCursor: string | null;
}

export default function InfiniteFeed({ initialPosts, initialCursor }: Props) {
  const [posts, setPosts] = useState<Post[]>(initialPosts);
  const [cursor, setCursor] = useState<string | null>(initialCursor);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const sentinel = useRef<HTMLDivElement>(null);

  const loadMore = useCallback(async () => {
    if (!cursor || loading) return;

    setLoading(true);
    setError(null);

    try {
      const res = await fetch(`/api/instagram/posts?cursor=${encodeURIComponent(cursor)}`);
      const data = await res.json();

      if (!res.ok) {
        throw new Error(data.error ?? 'Could not load more posts');
      }

      const pagination: Pagination = data.pagination;

      setPosts((previous) => [...previous, ...data.posts]);
      setCursor(pagination.hasMore ? pagination.nextCursor : null);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Could not load more posts');
    } finally {
      setLoading(false);
    }
  }, [cursor, loading]);

  // Load the next page when the sentinel scrolls into view.
  // After an error, stop loading automatically and wait for the button.
  useEffect(() => {
    const element = sentinel.current;
    if (!element || !cursor || error) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) loadMore();
      },
      { rootMargin: '400px' },
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [cursor, error, loadMore]);

  return (
    <div>
      <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
        {posts.map((post) => {
          const src = imageFor(post, '320');

          return (
            <a
              key={post.id}
              href={post.permalink}
              target="_blank"
              rel="noopener noreferrer"
              className="relative block aspect-square overflow-hidden rounded-lg bg-gray-100"
            >
              {src && (
                <Image
                  src={src}
                  alt={post.altText ?? post.caption ?? 'Instagram post'}
                  fill
                  sizes="(min-width: 768px) 33vw, 50vw"
                  className="object-cover"
                />
              )}
            </a>
          );
        })}
      </div>

      {cursor && (
        <div ref={sentinel} className="py-8 text-center">
          {error && <p className="mb-2 text-sm text-red-600">{error}</p>}
          <button onClick={loadMore} disabled={loading} className="rounded border px-4 py-2">
            {loading ? 'Loading...' : error ? 'Try again' : 'Load more'}
          </button>
        </div>
      )}
    </div>
  );
}

The button loads the next page by hand, and becomes a retry button after an error.

Playing videos

For VIDEO posts, thumbnailUrl is a JPEG you can use as the poster and mediaUrl is usually the video file. Instagram doesn't always provide the video file, in which case mediaUrl is a JPEG too. Check the extension before using it as a video source:

// components/PostMedia.tsx

import { imageFor, type Post } from '@/lib/feedframer';

export default function PostMedia({ post }: { post: Post }) {
  const isPlayableVideo = post.mediaType === 'VIDEO' && /\.mp4(\?|$)/.test(post.mediaUrl);

  if (isPlayableVideo) {
    return (
      <video
        src={post.mediaUrl}
        poster={post.thumbnailUrl ?? undefined}
        controls
        playsInline
        preload="none"
        className="w-full"
      />
    );
  }

  const src = imageFor(post, '960');

  return src ? <img src={src} alt={post.altText ?? post.caption ?? ''} className="w-full" /> : null;
}

Pages Router

getStaticProps

Build the page statically and regenerate it in the background at most once an hour:

// pages/instagram.tsx

import type { GetStaticProps } from 'next';
import Image from 'next/image';
import { getFeed, imageFor, type Post } from '@/lib/feedframer';

interface Props {
  username: string;
  posts: Post[];
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  // getFeed() throws if res.ok is false. During a background regeneration,
  // Next.js then keeps serving the last page it built successfully.
  const feed = await getFeed();

  return {
    props: { username: feed.username, posts: feed.posts },
    revalidate: 3600,
  };
};

export default function InstagramPage({ username, posts }: Props) {
  return (
    <main>
      <h1>@{username} on Instagram</h1>

      <div className="grid grid-cols-3 gap-4">
        {posts.map((post) => {
          const src = imageFor(post);

          return (
            <a key={post.id} href={post.permalink} target="_blank" rel="noopener noreferrer">
              {src && (
                <Image
                  src={src}
                  alt={post.altText ?? post.caption ?? 'Instagram post'}
                  width={320}
                  height={320}
                  style={{ objectFit: 'cover' }}
                />
              )}
            </a>
          );
        })}
      </div>
    </main>
  );
}

If the request fails during next build, the build fails. That's usually what you want, since the key or network is wrong. To build anyway, catch the error and return { props: { username: '', posts: [] }, revalidate: 60 } so Next.js retries a minute later.

getServerSideProps

If you'd rather render on every request, the same helper works in getServerSideProps. Feedframer's edge cache still serves repeat requests for the same URL for up to an hour.

// pages/instagram-live.tsx

import type { GetServerSideProps } from 'next';
import { getFeed, type Post } from '@/lib/feedframer';

interface Props {
  posts: Post[];
  error: string | null;
}

export const getServerSideProps: GetServerSideProps<Props> = async () => {
  try {
    const feed = await getFeed();
    return { props: { posts: feed.posts, error: null } };
  } catch (error) {
    console.error('Feedframer request failed', error);
    return { props: { posts: [], error: 'Instagram posts are unavailable right now.' } };
  }
};

export default function InstagramLivePage({ posts, error }: Props) {
  if (error) return <p>{error}</p>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <a href={post.permalink}>{post.caption ?? 'View on Instagram'}</a>
        </li>
      ))}
    </ul>
  );
}

API route (Pages Router)

The Pages Router equivalent of the route handler above. It validates type and forwards cursor in the same way:

// pages/api/instagram/posts.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import { FeedframerError, getFeed, TYPE_FILTERS, type TypeFilter } from '@/lib/feedframer';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', 'GET');
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const type = typeof req.query.type === 'string' ? req.query.type.toUpperCase() : undefined;
  const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;

  if (type && !TYPE_FILTERS.includes(type as TypeFilter)) {
    return res.status(400).json({ error: `type must be one of ${TYPE_FILTERS.join(', ')}` });
  }

  try {
    const feed = await getFeed({ type: type as TypeFilter | undefined, cursor });

    res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=600');
    return res.status(200).json({ posts: feed.posts, pagination: feed.pagination });
  } catch (error) {
    console.error('Feedframer request failed', error);

    const code = error instanceof FeedframerError ? error.code : 'NETWORK_ERROR';

    return res.status(502).json({ error: 'Could not load Instagram posts', code });
  }
}

The Cache-Control header lets a CDN in front of your app (such as Vercel's) cache the proxy response. In the Pages Router, fetch's next.revalidate option has no effect, so this header is what stops every visitor's request reaching Feedframer.

Filtering

getFeed() accepts the same filters as the REST API.

import { getFeed } from '@/lib/feedframer';

// Only images
const images = await getFeed({ type: 'IMAGE' });

// Only videos. REELS returns the same posts, because Instagram reports reels as VIDEO.
const videos = await getFeed({ type: 'VIDEO' });

// Posts published in the first quarter of 2026. Both dates are inclusive,
// and a plain date as publishedBefore includes that whole day (UTC).
const firstQuarter = await getFeed({
  publishedAfter: '2026-01-01',
  publishedBefore: '2026-03-31',
});

On a free key, filters apply within your 6 most recent posts. type: 'VIDEO' returns only the videos among those 6, which may be none.

mediaType in responses is only ever IMAGE, VIDEO or CAROUSEL_ALBUM. See Filtering & Sorting for sorting and the full parameter list.

GraphQL

If you prefer GraphQL, query me. Its arguments mirror the REST parameters: first (defaults to and is capped at your plan's limit), cursor and type. Note the field names differ from REST: type instead of mediaType, publishedAt instead of timestamp, and id is Feedframer's id, with Instagram's in instagramPostId.

// lib/feedframer-graphql.ts

const FEED_QUERY = `
  query Feed($cursor: String, $type: String) {
    me(cursor: $cursor, type: $type) {
      username
      posts {
        id
        instagramPostId
        type
        caption
        mediaUrl
        thumbnailUrl
        sizes
        permalink
        publishedAt
        likeCount
        commentsCount
      }
      pagination {
        nextCursor
        hasMore
      }
    }
  }
`;

export interface GraphQLPost {
  id: string;
  instagramPostId: string;
  type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
  caption: string | null;
  mediaUrl: string;
  thumbnailUrl: string | null;
  sizes: Record<'320' | '768' | '960' | '1200', string> | null; // null on free keys
  permalink: string;
  publishedAt: string;
  likeCount: number | null;
  commentsCount: number | null;
}

export interface GraphQLFeed {
  username: string;
  posts: GraphQLPost[];
  pagination: { nextCursor: string | null; hasMore: boolean };
}

export async function getFeedGraphQL(
  variables: { cursor?: string; type?: string } = {},
): Promise<GraphQLFeed> {
  const res = await fetch(
    `https://feedframer.com/graphql?api_key=${process.env.FEEDFRAMER_API_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: FEED_QUERY, variables }),
      next: { revalidate: 3600 },
    },
  );

  const body = await res.json();

  // A missing or invalid API key is an HTTP 401 with the REST error body.
  if (!res.ok) {
    throw new Error(body.errors?.[0]?.detail ?? `Feedframer returned HTTP ${res.status}`);
  }

  // Query problems (such as an invalid type) come back as HTTP 200 with errors.
  // Argument validation messages are in extensions.validation.
  if (body.errors?.length) {
    const [first] = body.errors;
    const validation = first.extensions?.validation;

    throw new Error(validation ? Object.values(validation).flat().join(' ') : first.message);
  }

  return body.data.me;
}

GraphQL responses aren't cached at Feedframer's edge, so the revalidate option matters more here than with REST. See the GraphQL API reference for every field.

Caching

Feedframer already caches successful REST responses at its edge for 1 hour. The cache is cleared automatically when a fetch finds new or changed posts, and you can clear it yourself with Purge Cache on the Dashboard. New posts arrive once a day on the free plan and once an hour on premium.

So there's little point caching for less than an hour in your app:

  • App Router: next: { revalidate: 3600 } on the fetch (as in getFeed()), or export const revalidate = 3600 on the page.
  • Pages Router: revalidate: 3600 in getStaticProps, and a Cache-Control: s-maxage=3600 header on API routes.
  • Free plan: posts only change daily, so revalidate: 86400 is fine too.

If you publish a post and want it on your site straight away, press Refresh Posts on the Dashboard, then redeploy or revalidate the page. On the App Router you can call revalidatePath('/instagram') from a server action or route handler that you protect yourself.

See Caching & Performance for details.

Error handling

Every Feedframer error response has the same shape, whatever Accept header you send:

{
  "errors": [
    {
      "status": "401",
      "code": "INVALID_API_KEY",
      "title": "Authentication Failed",
      "detail": "The provided API key is invalid."
    }
  ]
}

getFeed() turns this into a FeedframerError with status, code and message (the detail). The codes you can get are:

HTTP code What to do
401 MISSING_API_KEY No api_key was sent. getFeed() checks FEEDFRAMER_API_KEY before making the request, so with the helper you'll see its own "FEEDFRAMER_API_KEY is not set" error instead.
401 INVALID_API_KEY The key was regenerated, or the account was disconnected. Copy the current key from the Dashboard.
422 VALIDATION_ERROR A parameter is wrong, for example page[size] above 100. source.parameter names it.
500 INTERNAL_SERVER_ERROR Retry later. If it persists, contact [email protected].

Feedframer has no rate limits, so you won't see a 429. For a page that should still render when Feedframer can't be reached, catch the error and show a fallback:

import { FeedframerError, getFeed, type Post } from '@/lib/feedframer';

export async function getPostsOrEmpty(): Promise<Post[]> {
  try {
    const feed = await getFeed();
    return feed.posts;
  } catch (error) {
    if (error instanceof FeedframerError && error.status === 401) {
      console.error(`Feedframer key problem (${error.code}): ${error.message}`);
    } else {
      console.error('Feedframer request failed', error);
    }
    return [];
  }
}

See Errors for the full list.

Next steps