Skip to content

Creating a Full-Stack Component in Shadcn Easily

Written By Ajay Patel
25 min read

Creating a Full-Stack Component in Shadcn Easily

Shadcn/ui gives you beautiful components, but they’re frontend-only, no backend, no data fetching. Here’s how to create a Full-stack Component in Shadcn UI.

We’re building an Instagram profile viewer that pulls live data from your Instagram Business Account using Meta’s official Facebook Graph API. Real profile stats, actual photos, engagement metrics, all with production-ready caching and rate limiting.

Prerequisites

Before you start, make sure you have:

  • Node.js 18+ installed

  • A Facebook account (verified with phone and email)

  • An Instagram Business or Creator account

  • A Facebook Page (we’ll create one if needed)

  • Basic knowledge of Next.js and React

Understanding the Architecture

This isn’t just a component; it’s a complete feature with caching, rate limiting, and proper error handling. Here’s how the pieces fit together:

  • The UI Layer - Shadcn components (Cards, avatars, dialogs, badges) that users see and interact with.

  • The Server Layer - A Next.js API route that fetches data from Meta’s Facebook Graph API v25.0.

  • The Integration Layer - Meta’s Facebook Graph API. Official, authenticated, and reliable access to your Instagram Business Account data.

  • The Performance Layer - Two levels of caching:

  • In-memory Map cache (10 minutes) - Lightning fast, prevents redundant API calls

  • Next.js fetch cache (10 minutes) - Built-in request deduplication

  • The Protection Layer - Rate limiting (20 requests/minute per IP) to prevent abuse and protect your API quota.

  • The UX Layer - Loading skeletons, error messages, pop-up modals, and responsive grids.

⚠️ Important Limitations

You can ONLY access:

  • Your own Instagram Business Account

  • Public profile data

  • Posts you’ve published

You CANNOT access:

  • Other users’ accounts

  • Private accounts (even your own if set to private)

  • Stories or Reels (requires additional permissions)

  • Direct messages (requires additional permissions)

  • Detailed analytics (requires additional permissions)

Unlike web scraping methods, the official API doesn’t allow fetching data from other users’ profiles. This is a fundamental limitation of the Graph API.

For most use cases (portfolio sites, business websites, agency pages), this is perfect—you’re showing your own Instagram feed anyway.

Project Setup for Creating a Full-Stack Component in Shadcn

Let’s get a Next.js project running with Shadcn already configured. Use the Create app feature from Shadcn to make setup simple.

Your project structure should look like this:

shadcn-instagram/
├── app/
│   ├── api/
│   │   ├── instagram/
│   │   │   └── posts/
│   │   │       └── route.ts
│   │   └── proxy-image/
│   │       └── route.ts
│   ├── page.tsx
│   └── layout.tsx
├── components/
│   └── ui/
│       ├── card.tsx
│       ├── avatar.tsx
│       ├── dialog.tsx
│       └── ...
├── lib/
│   └── utils.ts
└── .env.local

Getting Your Meta API Credentials

Before we can fetch Instagram data, you need to set up a Meta (Facebook) Developer account and get your API credentials. This is required because we’re using the official Facebook Graph API.

Step 1: 🔐 Create a Meta Developer Account

  1. Navigate to Meta for Developers

  2. Log in with your Facebook account

  3. Important: Verify your account with both:

    • Phone number

    • Email address

Create a Meta Developer Account

Step 2: 📱 Create a New App

Create a New App

  • Click “Create App” (or “My Apps” → “Create App”)

  • Fill in the details:

    • App Name: Choose any name (e.g., “Instagram Feed Viewer”)

    • Use Case: In All, select “Manage messaging & content on Instagram.”

  • Complete the app creation process

Complete the app creation process

Step 3: ✅ Configure Permissions

Before generating your access token, you need to request the right permissions:

  1. Navigate to your app dashboard

  2. Go to Use Cases → Customize

  3. Click on Permissions and Features

  4. Add these permissions (you’ll need to request them):

pages_show_list
business_management
instagram_basic
pages_read_engagement
instagram_manage_contents

Step 4: 🔑 Generate Access Token

  • In the top navigation bar, click Tools → Graph API Explorer

  • Add the permissions we granted

  • Click “Generate Access Token”

  • Log in with your Facebook account and authorize

  • Copy and save this token - this is your FACEBOOK_PAGE_ACCESS_TOKEN

⚠️ Token Expiration:

  • Short-lived tokens: 1 hour (what you just generated)

  • Long-lived tokens: 60 days (recommended for production)

To get a long-lived token:

  • Go to Access Token Debugger

  • Paste your short-lived token

  • Click “Debug”

  • Click “Extend Access Token” at the bottom

  • Copy the new long-lived token

For automated token refresh, see the Meta documentation.

Step 5: 📸 Convert Instagram to Business Account

Your Instagram account must be a Business or Creator account:

  1. Open the Instagram app or web

  2. Go to SettingsAccount

  3. Tap Switch to Professional Account

  4. Choose Business or Creator

  5. Complete the setup process

Instagram Business Accounts must be linked to a Facebook Page:

  • Go to Facebook and create a new Page

    • You can name it anything

    • Category doesn’t matter for this use case

    • You can leave it mostly blank

  • Once created, go to Page Settings

  • Navigate to Settings → Linked Accounts (or Instagram) on the sidebar

Create and Link Facebook Page

  • Click Connect Account

  • Log in and authorize your Instagram account

Step 7: 🆔 Get Your Instagram Business Account ID

Now we need to find your Instagram Business Account ID:

  1. Go back to Graph API Explorer

  2. Make sure your app is selected

  3. In the search field, enter: me/accounts?fields=instagram_business_account

  4. Click “Submit”

You’ll get a response like:

{
  "data": [
    {
      "instagram_business_account": {
        "id": "17841469217838238"
      },
      "id": "96454546313330"
    }
  ]
}

Copy the instagram_business_account.id value—this is your INSTAGRAM_BUSINESS_ACCOUNT_ID.

Step 8: 🔐 Configure Environment Variables

Create a .env.local file in your project root:

FACEBOOK_PAGE_ACCESS_TOKEN=your_access_token_here
INSTAGRAM_BUSINESS_ACCOUNT_ID=your_instagram_account_id_here

⚠️ Never commit your

.env.local file to Git! Add it to .gitignore.

What Data Can You Access

With these credentials, you can fetch:

Profile Information:

fields: biography,followers_count,follows_count,media_count,name,profile_picture_url,username,website

Posts/Media:

fields: id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,like_count,comments_count

Why Caching Matters

Meta’s Graph API has rate limits. For development mode, you get around 200 calls per hour. For production apps, limits are higher but still finite.

Instagram profiles don’t change that fast. Followers update gradually, and posts go up once or twice a day. A 10-minute cache is perfectly reasonable and keeps you well under API limits.

Two-Layer Caching Strategy

Layer 1: In-Memory Map (10 min TTL)

  • Stores results in Node.js process memory

  • Instant lookups-zero network calls

  • Cleared when the server restarts

Layer 2: Next.js Fetch Cache (10 min TTL)

  • Built into Next.js fetch()

  • Deduplicates parallel requests

  • Persists across serverless invocations

Result: First request hits Meta API. Next 600 seconds? Instant responses.

Without caching, high traffic could exhaust your API quota. With caching, you can handle serious traffic while staying within limits.

Designing the Data Contract

Before we fetch anything, we need to know what we’re fetching. Here’s our TypeScript contract:

interface InstagramPost {
  id: string;
  image: string;
  thumbnail: string;
  caption: string;
  likes: number;
  comments: number;
  url: string;
  timestamp: string;
  type: 'image' | 'video' | 'carousel';
}

interface InstagramProfile {
  username: string;
  full_name: string;
  profile_pic: string;
  followers: number;
  following: number;
  posts_count: number;
  bio: string;
  is_private: boolean;
}

This contract defines exactly what our API returns and what our UI expects. No guessing, no any types, no surprises.

Building the Server Layer: Caching

Create app/api/instagram/posts/route.ts. We’ll build this in sections for clarity.

First, set up the caching system:

// =============================================================================
// CACHING SYSTEM
// =============================================================================

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  expiresAt: number;
}

const cache = new Map<string, CacheEntry<unknown>>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes

function getFromCache<T>(key: string): T | null {
  const entry = cache.get(key);
  if (!entry) return null;

  const now = Date.now();
  if (entry.expiresAt < now) {
    cache.delete(key);
    return null;
  }

  return entry.data as T;
}

function saveToCache<T>(key: string, data: T, ttl: number = CACHE_TTL): void {
  const now = Date.now();
  cache.set(key, {
    data,
    timestamp: now,
    expiresAt: now + ttl
  });

  // Memory management: cleanup old entries
  if (cache.size > 100) {
    for (const [key, value] of cache.entries()) {
      if (value.expiresAt < now) {
        cache.delete(key);
      }
    }
  }
}

Create app/api/instagram/posts/route.ts. We’ll build this in sections for clarity.

First, set up the caching system:

// =============================================================================
// CACHING SYSTEM
// =============================================================================

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  expiresAt: number;
}

const cache = new Map<string, CacheEntry<unknown>>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes

function getFromCache<T>(key: string): T | null {
  const entry = cache.get(key);
  if (!entry) return null;

  const now = Date.now();
  if (entry.expiresAt < now) {
    cache.delete(key);
    return null;
  }

  return entry.data as T;
}

function saveToCache<T>(key: string, data: T, ttl: number = CACHE_TTL): void {
  const now = Date.now();
  cache.set(key, {
    data,
    timestamp: now,
    expiresAt: now + ttl
  });

  // Memory management: cleanup old entries
  if (cache.size > 100) {
    for (const [key, value] of cache.entries()) {
      if (value.expiresAt < now) {
        cache.delete(key);
      }
    }
  }
}

Building the Server Layer: Rate Limiting

Next, add rate limiting to protect your API quota:

// =============================================================================
// RATE LIMITING
// =============================================================================

interface RateLimitEntry {
  count: number;
  resetAt: number;
}

const rateLimits = new Map<string, RateLimitEntry>();
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 20; // 20 requests per minute

function checkRateLimit(identifier: string): { allowed: boolean; retryAfter?: number } {
  const now = Date.now();
  const entry = rateLimits.get(identifier);

  if (!entry || entry.resetAt < now) {
    rateLimits.set(identifier, {
      count: 1,
      resetAt: now + RATE_LIMIT_WINDOW
    });
    return { allowed: true };
  }

  if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
    const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
    return { allowed: false, retryAfter };
  }

  entry.count++;
  return { allowed: true };
}

// Periodic cleanup to prevent memory leaks
const cleanupInterval = setInterval(() => {
  const now = Date.now();
  for (const [key, value] of rateLimits.entries()) {
    if (value.resetAt < now) {
      rateLimits.delete(key);
    }
  }
}, 60000);

// Clean up on shutdown
if (typeof process !== 'undefined') {
  process.on('SIGTERM', () => clearInterval(cleanupInterval));
}

Building the Server Layer: Type Definitions

Define the types for your API responses:

// =============================================================================
// TYPES
// =============================================================================

interface InstagramPost {
  id: string;
  image: string;
  thumbnail: string;
  caption: string;
  likes: number;
  comments: number;
  url: string;
  timestamp: string;
  type: 'image' | 'video' | 'carousel';
}

interface InstagramProfile {
  username: string;
  full_name: string;
  profile_pic: string;
  followers: number;
  following: number;
  posts_count: number;
  bio: string;
  is_private: boolean;
}

// =============================================================================
// INSTAGRAM FETCHER (FACEBOOK GRAPH API)
// =============================================================================

async function fetchInstagramPosts(): Promise<{
  profile: InstagramProfile;
  posts: InstagramPost[];
}> {
  // Check cache first
  const cacheKey = 'instagram:profile';
  const cacheEntry = cache.get(cacheKey);
  const cached = getFromCache<{ profile: InstagramProfile; posts: InstagramPost[] }>(cacheKey);

  if (cached) {
    const now = Date.now();
    const age = cacheEntry ? Math.floor((now - cacheEntry.timestamp) / 1000) : 0;
    const expiresIn = cacheEntry ? Math.floor((cacheEntry.expiresAt - now) / 1000) : 0;

    console.log(`✅ Cache HIT (age: ${age}s, expires in: ${expiresIn}s)`);
    return cached;
  }

  console.log(`🔄 Cache MISS - Fetching from Facebook Graph API`);

  // Get credentials from environment variables
  const pageAccessToken = process.env.FACEBOOK_PAGE_ACCESS_TOKEN;
  const instagramBusinessAccountId = process.env.INSTAGRAM_BUSINESS_ACCOUNT_ID;

  if (!pageAccessToken || !instagramBusinessAccountId) {
    throw new Error('Missing Facebook API credentials. Please set FACEBOOK_PAGE_ACCESS_TOKEN and INSTAGRAM_BUSINESS_ACCOUNT_ID environment variables.');
  }

  // Fetch profile info from Graph API v25.0
  const profileUrl = `https://graph.facebook.com/v25.0/${instagramBusinessAccountId}`;
  const profileParams = new URLSearchParams({
    fields: 'biography,followers_count,follows_count,media_count,name,profile_picture_url,username,website',
    access_token: pageAccessToken
  });

  const profileResponse = await fetch(`${profileUrl}?${profileParams}`, {
    next: { revalidate: 600 } // Next.js cache: 10 minutes
  });

  if (!profileResponse.ok) {
    const errorData = await profileResponse.json().catch(() => ({}));
    throw new Error(`Facebook Graph API returned ${profileResponse.status}: ${errorData.error?.message || 'Unknown error'}`);
  }

  const profileData = await profileResponse.json();

  if (profileData.error) {
    throw new Error(`Facebook Graph API error: ${profileData.error.message}`);
  }

  // Fetch posts from Graph API
   const postsUrl = `https://graph.facebook.com/v25.0/${instagramBusinessAccountId}/media`;
  const postsParams = new URLSearchParams({
    fields: 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
    access_token: pageAccessToken,
    limit: '16'
  });

  const postsResponse = await fetch(`${postsUrl}?${postsParams}`, {
    next: { revalidate: 600 }
  });

  if (!postsResponse.ok) {
    const errorData = await postsResponse.json().catch(() => ({}));
    throw new Error(`Facebook Graph API returned ${postsResponse.status}: ${errorData.error?.message || 'Unknown error'}`);
  }

  const postsData = await postsResponse.json();

  if (postsData.error) {
    throw new Error(`Facebook Graph API error: ${postsData.error.message}`);
  }

  // Parse profile data
  const profile: InstagramProfile = {
    username: profileData.username || '',
    full_name: profileData.name || '',
    profile_pic: profileData.profile_picture_url || '',
    followers: profileData.followers_count || 0,
    following: profileData.follows_count || 0,
    posts_count: profileData.media_count || 0,
    bio: profileData.biography || '',
    is_private: false // Business accounts are always public
  };

  const posts: InstagramPost[] = (postsData.data || []).map((post: {
    id: string;
    caption?: string;
    media_type: string;
    media_url: string;
    permalink: string;
    thumbnail_url?: string;
    timestamp: string;
    like_count?: number;
    comments_count?: number;
    children?: {
      data: Array<{
        id: string;
        media_type: string;
        media_url: string;
        thumbnail_url?: string;
      }>;
    };
  }) => {
    // Handle carousel albums by extracting all children media
    let carouselMedia: Array<{ url: string; type: 'IMAGE' | 'VIDEO'; thumbnail?: string }> | undefined;
    
    if (post.media_type === 'CAROUSEL_ALBUM' && post.children?.data) {
      carouselMedia = post.children.data.map(child => ({
        url: child.media_url,
        type: child.media_type as 'IMAGE' | 'VIDEO',
        thumbnail: child.media_type === 'VIDEO' ? child.thumbnail_url : undefined
      }));
    }

    return {
      id: post.id,
      image: post.media_type === 'VIDEO' ? post.media_url : post.media_url,
      thumbnail: post.media_type === 'VIDEO' ? (post.thumbnail_url || post.media_url) : post.media_url,
      caption: post.caption || '',
      likes: post.like_count || 0,
      comments: post.comments_count || 0,
      url: post.permalink || '',
      timestamp: post.timestamp || new Date().toISOString(),
      type: post.media_type === 'VIDEO' ? 'video' : post.media_type === 'CAROUSEL_ALBUM' ? 'carousel' : 'image',
      carouselMedia
    };
  });

  const result = { profile, posts };

  // Cache the result
  saveToCache(cacheKey, result);
  console.log(`💾 Cached profile for ${Math.floor(CACHE_TTL / 1000)}s`);

  return result;
}

Building the Server Layer: API Route Handler

Finally, wire everything together in the route handler:

import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs'
// ... (all the code from previous sections goes here)

// =============================================================================
// API ROUTE HANDLER
// =============================================================================

export async function GET(request: NextRequest) {
  // Note: This fetches YOUR OWN Instagram Business Account only

  // Rate limiting by IP
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
             request.headers.get('x-real-ip') ||
             request.headers.get('cf-connecting-ip') ||
             'unknown';

  const rateLimitResult = checkRateLimit(ip);

  if (!rateLimitResult.allowed) {
    return NextResponse.json(
      {
        success: false,
        error: 'Too many requests',
        hint: `Rate limit exceeded. Please try again in ${rateLimitResult.retryAfter} seconds.`
      },
      {
        status: 429,
        headers: {
          'Retry-After': rateLimitResult.retryAfter?.toString() || '60',
          'X-RateLimit-Limit': RATE_LIMIT_MAX_REQUESTS.toString(),
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': Math.floor(Date.now() / 1000 + (rateLimitResult.retryAfter || 60)).toString()
        }
      }
    );
  }

  try {
    const { profile, posts } = await fetchInstagramPosts();
    const currentLimit = rateLimits.get(ip);
    const remaining = RATE_LIMIT_MAX_REQUESTS - (currentLimit?.count || 0);

    return NextResponse.json(
      {
        success: true,
        profile,
        posts
      },
      {
        headers: {
          'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=300',
          'X-RateLimit-Limit': RATE_LIMIT_MAX_REQUESTS.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': currentLimit
            ? Math.floor(currentLimit.resetAt / 1000).toString()
            : Math.floor((Date.now() + RATE_LIMIT_WINDOW) / 1000).toString()
        }
      }
    );
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Failed to fetch Instagram data';
    const isMissingCredentials = message.includes('Missing Facebook API credentials');
    const isApiError = message.includes('Facebook Graph API');

    return NextResponse.json(
      {
        success: false,
        error: message,
        hint: isMissingCredentials
          ? 'Please configure FACEBOOK_PAGE_ACCESS_TOKEN and INSTAGRAM_BUSINESS_ACCOUNT_ID in your environment variables.'
          : isApiError
            ? 'There was an issue with the Facebook Graph API. Please check your access token and account ID.'
            : 'Make sure your Facebook Page is connected to an Instagram Business Account and your access token is valid.'
      },
      {
        status: isMissingCredentials ? 500 : 500,
        headers: {
          'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600'
        }
      }
    );
  }
}

This code uses official APIs, handles errors gracefully, rate-limits requests, and caches responses—ready for production.

Building the Image Proxy

Instagram images have CORS restrictions that prevent direct loading in browsers. We need a proxy to fetch them server-side.

Create app/api/proxy-image/route.ts:

import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs';

// Only proxy images from trusted Instagram CDN hostnames
const ALLOWED_HOSTS = [
  'instagram.famd12-1.fna.fbcdn.net',
  'instagram.fblr1-1.fna.fbcdn.net',
  'scontent.cdninstagram.com',
  'scontent-sin6-3.cdninstagram.com',
  'cdninstagram.com'
];

function isAllowed(url: string): boolean {
  try {
    const { hostname } = new URL(url);
    return ALLOWED_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h) || hostname.endsWith('fbcdn.net') || hostname.endsWith('cdninstagram.com'));
  } catch {
    return false;
  }
}

export async function GET(request: NextRequest) {
  const imageUrl = request.nextUrl.searchParams.get('url');

  if (!imageUrl) {
    return new NextResponse('Missing url parameter', { status: 400 });
  }

  if (!isAllowed(imageUrl)) {
    return new NextResponse('URL not allowed', { status: 403 });
  }

  try {
    const response = await fetch(imageUrl, {
      headers: {
        // Mimic a browser request so Instagram CDN serves the image
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
        Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
        Referer: '<https://www.instagram.com/>'
      },
      cache: 'no-store'
    });

    if (!response.ok) {
      return new NextResponse('Failed to fetch image', { status: response.status });
    }

    const contentType = response.headers.get('content-type') || 'image/jpeg';
    const buffer = await response.arrayBuffer();

    return new NextResponse(buffer, {
      status: 200,
      headers: {
        'Content-Type': contentType,
        // Cache on CDN/browser for 1 hour — Instagram URLs expire anyway
        'Cache-Control': 'public, max-age=3600, stale-while-revalidate=600',
        // Allow cross-origin so the widget JS (any origin) can load images
        'Cross-Origin-Resource-Policy': 'cross-origin',
        'Access-Control-Allow-Origin': '*'
      }
    });
  } catch {
    return new NextResponse('Proxy error', { status: 500 });
  }
}

This proxy:

  • Validates domains to prevent open redirect vulnerabilities

  • Fetches images server-side to bypass CORS restrictions

  • Caches aggressively (1 year) since Instagram images don’t change

  • Logs errors for debugging

Building the UI Component

Now for the frontend. This goes in app/page.tsx as a complete page component.

"use client";

import { useEffect, useState } from "react";
import {
  Play,
  Layers,
  Heart,
  MessageCircle,
  X,
  AlertCircle,
  Camera,
  CheckCircle,
  Image as ImageIcon,
  ChevronLeft,
  ChevronRight,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogClose,
  DialogTitle,
  DialogDescription,
} from "@/components/ui/dialog";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";

function proxyImg(url: string): string {
  if (!url) return url;
  if (url.startsWith("/api/proxy-image") || url.startsWith("/")) return url;
  return "/api/proxy-image?url=" + encodeURIComponent(url);
}

interface InstagramPost {
  id: string;
  image: string;
  thumbnail: string;
  caption: string;
  likes: number;
  comments: number;
  url: string;
  timestamp: string;
  type: "image" | "video" | "carousel";
  carouselMedia?: Array<{
    url: string;
    type: "IMAGE" | "VIDEO";
    thumbnail?: string;
  }>;
}

interface InstagramProfile {
  username: string;
  full_name: string;
  profile_pic: string;
  followers: number;
  following: number;
  posts_count: number;
  bio: string;
  is_private: boolean;
}

function formatCount(n: number): string {
  if (n >= 1_000_000)
    return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
  if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
  return n.toString();
}

const PostTypeIcon = ({ type }: { type: InstagramPost["type"] }) => {
  if (type === "video") {
    return <Play className="w-4 h-4" fill="white" />;
  }
  if (type === "carousel") {
    return <Layers className="w-4 h-4" fill="white" />;
  }
  return null;
};

interface PostModalProps {
  post: InstagramPost;
  profile: InstagramProfile;
  open: boolean;
  onClose: () => void;
}

function PostModal({ post, profile, open, onClose }: PostModalProps) {
  const [currentMediaIndex, setCurrentMediaIndex] = useState(0);

  // Reset carousel index when post changes
  useEffect(() => {
    setCurrentMediaIndex(0);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [post.id]);

  // Determine media to display
  const mediaItems =
    post.type === "carousel" && post.carouselMedia
      ? post.carouselMedia
      : [
          {
            url: post.image,
            type:
              post.type === "video" ? "VIDEO" : ("IMAGE" as "IMAGE" | "VIDEO"),
            thumbnail: post.thumbnail,
          },
        ];

  const currentMedia = mediaItems[currentMediaIndex];
  const hasMultipleMedia = mediaItems.length > 1;

  const goToPrevious = () => {
    setCurrentMediaIndex((prev) =>
      prev > 0 ? prev - 1 : mediaItems.length - 1,
    );
  };

  const goToNext = () => {
    setCurrentMediaIndex((prev) =>
      prev < mediaItems.length - 1 ? prev + 1 : 0,
    );
  };

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent
        className="max-w-4xl! p-0 gap-0 overflow-hidden max-h-150! h-full"
        showCloseButton={false}
      >
        <DialogTitle className="sr-only">
          Post by {profile.username}
        </DialogTitle>
        <DialogDescription className="sr-only">
          Instagram post with {formatCount(post.likes)} likes and{" "}
          {formatCount(post.comments)} comments
        </DialogDescription>
        <div className="bg-white rounded-2xl overflow-hidden w-full max-h-[90vh] flex flex-col md:flex-row">
          {/* Media - Image or Video */}
          <div className="relative md:w-[55%] bg-black shrink-0 aspect-square md:aspect-auto">
            {currentMedia.type === "VIDEO" ? (
              <video
                src={proxyImg(currentMedia.url)}
                className="size-full object-contain"
                controls
                autoPlay
                loop
                playsInline
              >
                <track kind="captions" />
                Your browser does not support the video tag.
              </video>
            ) : (
              // eslint-disable-next-line @next/next/no-img-element
              <img
                src={proxyImg(currentMedia.url)}
                alt={post.caption}
                className="size-full object-fit"
              />
            )}

            {/* Media type indicator */}
            {!hasMultipleMedia &&
              (post.type === "video" || post.type === "carousel") && (
                <div className="absolute top-3 right-3 bg-black/50 rounded-full p-1.5">
                  <PostTypeIcon type={post.type} />
                </div>
              )}

            {/* Carousel navigation */}
            {hasMultipleMedia && (
              <>
                {/* Previous button */}
                <Button
                  onClick={goToPrevious}
                  variant="ghost"
                  size="icon"
                  className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/90 hover:bg-white shadow-lg rounded-full h-8 w-8 z-10"
                >
                  <ChevronLeft className="w-5 h-5 text-gray-700" />
                </Button>

                {/* Next button */}
                <Button
                  onClick={goToNext}
                  variant="ghost"
                  size="icon"
                  className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/90 hover:bg-white shadow-lg rounded-full h-8 w-8 z-10"
                >
                  <ChevronRight className="w-5 h-5 text-gray-700" />
                </Button>

                {/* Carousel indicator dots */}
                <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-1.5">
                  {mediaItems.map((_, index) => (
                    <button
                      key={`${post.id}-media-${index}`}
                      type="button"
                      onClick={() => setCurrentMediaIndex(index)}
                      className={`w-1.5 h-1.5 rounded-full transition-all ${
                        index === currentMediaIndex
                          ? "bg-white w-2 h-2"
                          : "bg-white/50"
                      }`}
                      aria-label={`Go to media ${index + 1}`}
                    />
                  ))}
                </div>

                {/* Counter */}
                <div className="absolute top-3 right-3 bg-black/50 rounded-full px-3 py-1 text-white text-sm font-medium">
                  {currentMediaIndex + 1} / {mediaItems.length}
                </div>
              </>
            )}
          </div>

          {/* Details */}
          <div className="flex flex-col flex-1 overflow-hidden">
            {/* Header */}
            <div className="flex items-center gap-3 p-4 border-b">
              <Avatar className="w-9 h-9 ring-2 ring-pink-400 ring-offset-1">
                <AvatarImage
                  src={proxyImg(profile.profile_pic)}
                  alt={profile.username}
                />
                <AvatarFallback>
                  {profile.username.slice(0, 2).toUpperCase()}
                </AvatarFallback>
              </Avatar>
              <div className="flex-1 min-w-0">
                <p className="font-semibold text-sm  truncate">
                  {profile.username}
                </p>
              </div>
              <a
                href={post.url}
                target="_blank"
                rel="noopener noreferrer"
                className="text-xs text-primary hover:underline shrink-0"
              >
                View on Instagram ↗
              </a>
            </div>

            {/* Caption */}
            <div className="flex-1 overflow-y-auto p-4">
              {post.caption && (
                <p className="text-sm text-foreground leading-relaxed whitespace-pre-line">
                  {post.caption}
                </p>
              )}
            </div>

            {/* Stats */}
            <div className="p-4 border-t space-y-1">
              <div className="flex items-center gap-3">
                <Button variant="outline" size="icon">
                  <Heart className="w-6 h-6" />
                </Button>
                <Button variant="outline" size="icon">
                  <MessageCircle className="w-6 h-6" />
                </Button>
              </div>
              <p className="font-semibold text-sm">
                {formatCount(post.likes)} likes
              </p>
              <p className="text-xs text-muted-foreground">
                {formatCount(post.comments)} comments
              </p>
            </div>
          </div>

          {/* Close button */}
          <DialogClose asChild>
            <Button
              variant="ghost"
              size="icon"
              className="absolute top-4 right-4 bg-black/50 text-white hover:bg-black/70 rounded-full z-10"
            >
              <X className="w-4 h-4" />
            </Button>
          </DialogClose>
        </div>
      </DialogContent>
    </Dialog>
  );
}

export default function InstagramProfilePage() {
  const [profile, setProfile] = useState<InstagramProfile | null>(null);
  const [posts, setPosts] = useState<InstagramPost[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [selectedPost, setSelectedPost] = useState<InstagramPost | null>(null);
  const [currentPage, setCurrentPage] = useState(0);

  const POSTS_PER_PAGE = 4; // 2x2 grid
  const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE);
  const currentPosts = posts.slice(
    currentPage * POSTS_PER_PAGE,
    (currentPage + 1) * POSTS_PER_PAGE,
  );

  const handlePrevious = () => {
    setCurrentPage((prev) => (prev > 0 ? prev - 1 : totalPages - 1));
  };

  const handleNext = () => {
    setCurrentPage((prev) => (prev < totalPages - 1 ? prev + 1 : 0));
  };

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        // Fetch from your configured Instagram Business Account
        const res = await fetch("/api/instagram/posts");
        const data = await res.json();

        if (!data.success) {
          throw new Error(data.error || "Failed to fetch data");
        }

        setProfile(data.profile);
        setPosts(data.posts); // Show all posts for carousel
      } catch (err) {
        setError(err instanceof Error ? err.message : "Something went wrong");
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  return (
    <div className="min-h-screen bg-white flex items-center justify-center p-4">
      <div className="w-full max-w-md">
        {/* Error */}
        {error && (
          <Card className="mb-6 border-red-200 bg-red-50">
            <CardContent className="pt-6">
              <div className="flex items-start gap-3 text-red-700">
                <AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
                <div>
                  <p className="font-semibold">Failed to load</p>
                  <p className="text-sm mt-0.5 opacity-80">{error}</p>
                </div>
              </div>
            </CardContent>
          </Card>
        )}

        {/* Loading Skeleton */}
        {loading && (
          <Card className="overflow-hidden shadow-lg">
            <CardContent className="p-0 animate-pulse">
              {/* Profile skeleton */}
              <div className="flex flex-col items-center px-6 pt-8 pb-6">
                <div className="w-24 h-24 rounded-full bg-gray-200 mb-4" />
                <div className="h-6 bg-gray-200 rounded w-40 mb-2" />
                <div className="h-4 bg-gray-200 rounded w-32 mb-4" />
                <div className="flex gap-8 justify-center mb-5 py-2">
                  <div className="space-y-1">
                    <div className="h-4 bg-gray-200 rounded w-12" />
                    <div className="h-3 bg-gray-200 rounded w-12" />
                  </div>
                  <div className="space-y-1">
                    <div className="h-4 bg-gray-200 rounded w-16" />
                    <div className="h-3 bg-gray-200 rounded w-16" />
                  </div>
                  <div className="space-y-1">
                    <div className="h-4 bg-gray-200 rounded w-16" />
                    <div className="h-3 bg-gray-200 rounded w-16" />
                  </div>
                </div>
                <div className="h-9 bg-gray-200 rounded-lg w-32" />
              </div>
              {/* Grid skeleton */}
              <div className="grid grid-cols-2 gap-0">
                <div className="aspect-square bg-gray-200 border border-gray-100" />
                <div className="aspect-square bg-gray-300 border border-gray-100" />
                <div className="aspect-square bg-gray-300 border border-gray-100" />
                <div className="aspect-square bg-gray-200 border border-gray-100" />
              </div>
            </CardContent>
          </Card>
        )}

        {/* Profile Header */}
        {profile && !loading && (
          <Card className="overflow-hidden shadow-none p-0 boder-0">
            <CardContent className="p-0">
              {/* Profile Info Section */}
              <div className="flex flex-col items-center px-6 pt-8 pb-6 bg-white">
                {/* Profile Picture with gradient ring */}
                <div className="relative mb-2">
                  <div className="w-24 h-24 rounded-full p-0.5 bg-linear-to-tr from-yellow-400 via-pink-500 to-purple-600">
                    <div className="w-full h-full rounded-full overflow-hidden bg-white p-0.5">
                      <Avatar className="w-full h-full">
                        <AvatarImage
                          src={proxyImg(profile.profile_pic)}
                          alt={profile.username}
                        />
                        <AvatarFallback className="text-2xl">
                          {profile.username.slice(0, 2).toUpperCase()}
                        </AvatarFallback>
                      </Avatar>
                    </div>
                  </div>
                </div>

                {/* Profile Name & Username */}
                <div className="text-center mb-2">
                  <div className="flex items-center justify-center gap-2 mb-1">
                    <h1 className="text-xl font-bold text-gray-900">
                      {profile.full_name || profile.username}
                    </h1>
                    <CheckCircle className="w-5 h-5 text-blue-500 fill-current" />
                  </div>
                  <p className="text-sm text-gray-600">@{profile.username}</p>
                </div>

                {/* Stats Row */}
                <div className="flex gap-8 justify-center items-center mb-3 py-2">
                  <div className="text-center">
                    <div className="font-bold text-gray-900 text-base">
                      {formatCount(profile.posts_count)}
                    </div>
                    <div className="text-xs text-gray-600">Posts</div>
                  </div>
                  <div className="text-center">
                    <div className="font-bold text-gray-900 text-base">
                      {formatCount(profile.followers)}
                    </div>
                    <div className="text-xs text-gray-600">Followers</div>
                  </div>
                  <div className="text-center">
                    <div className="font-bold text-gray-900 text-base">
                      {formatCount(profile.following)}
                    </div>
                    <div className="text-xs text-gray-600">Following</div>
                  </div>
                </div>

                {/* Follow Button */}
                <Button
                  asChild
                  className="bg-[#0095f6] hover:bg-[#1877f2] text-white rounded-lg h-9 px-8 text-sm font-semibold shadow-sm"
                >
                  <a
                    href={`https://www.instagram.com/${profile.username}/`}
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    <Camera className="w-4 h-4 mr-2" />
                    Follow
                  </a>
                </Button>
              </div>

              {/* Posts Carousel Section */}
              {posts.length > 0 && (
                <div className="relative bg-white">
                  {/* Posts Grid - 2x2 */}
                  <div className="grid grid-cols-2 gap-0">
                    {currentPosts.map((post) => (
                      <Button
                        key={post.id}
                        variant="ghost"
                        onClick={() => setSelectedPost(post)}
                        className="relative aspect-square group overflow-hidden rounded-none p-0 h-auto hover:bg-transparent cursor-pointer border border-gray-100"
                      >
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={proxyImg(post.thumbnail || post.image)}
                          alt={post.caption}
                          className="w-full h-full object-cover"
                        />
                        {/* Overlay for video/carousel indicators */}
                        {(post.type === "video" ||
                          post.type === "carousel") && (
                          <div className="absolute top-2 right-2 bg-black/60 rounded-full p-1">
                            <PostTypeIcon type={post.type} />
                          </div>
                        )}
                        {/* Hover overlay */}
                        <div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity" />
                      </Button>
                    ))}
                  </div>

                  {/* Navigation Arrows */}
                  {totalPages > 1 && (
                    <>
                      {/* Left Arrow */}
                      <Button
                        onClick={handlePrevious}
                        variant="ghost"
                        size="icon"
                        className="absolute cursor-pointer left-2 top-1/2 -translate-y-1/2 bg-white/90 hover:bg-white shadow-lg rounded-full h-10 w-10 z-10"
                      >
                        <ChevronLeft className="w-6 h-6 text-gray-700" />
                      </Button>

                      {/* Right Arrow */}
                      <Button
                        onClick={handleNext}
                        variant="ghost"
                        size="icon"
                        className="absolute cursor-pointer right-2 top-1/2 -translate-y-1/2 bg-white/90 hover:bg-white shadow-lg rounded-full h-10 w-10 z-10"
                      >
                        <ChevronRight className="w-6 h-6 text-gray-700" />
                      </Button>
                    </>
                  )}
                </div>
              )}

              {/* No Posts */}
              {posts.length === 0 && !profile.is_private && (
                <div className="p-12 text-center text-gray-500">
                  <ImageIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
                  <p className="text-sm font-medium">No posts yet</p>
                </div>
              )}
            </CardContent>
          </Card>
        )}
      </div>

      {/* Post Modal */}
      {selectedPost && profile && (
        <PostModal
          post={selectedPost}
          profile={profile}
          open={!!selectedPost}
          onClose={() => setSelectedPost(null)}
        />
      )}
    </div>
  );
}

This component showcases production-ready patterns:

  • State Management - Separate states for profile, posts, loading, errors, and selected post.

  • Loading Skeletons - Animated placeholders that match the final layout.

  • Error Handling - User-friendly error messages with clear context.

  • Post Modal - Full-screen dialog with post details, accessible and responsive.

  • Image Proxy - Routes images through our API to avoid CORS issues.

  • Responsive Grid - 3-column grid that adapts to mobile screens.

  • Hover Effects - Instagram-style overlay showing likes/comments on hover.

  • Type Safety - Full TypeScript coverage with no any types.

Testing It Out

Run your dev server:

npm run dev

Open: http://localhost:3000 and you should see your Instagram profile with posts.

full-stack component in shadcn

Troubleshooting Guide

”Missing Facebook API credentials.”

  • Check that .env.local exists in your project root

  • Verify both FACEBOOK_PAGE_ACCESS_TOKEN and INSTAGRAM_BUSINESS_ACCOUNT_ID are set

  • Restart your dev server after adding environment variables

”Invalid OAuth access token”

  • Your token expired (short-lived tokens last 1 hour)

  • Generate a new token in Graph API Explorer

  • Or extend to a long-lived token (60 days) in Access Token Debugger

”Unsupported get request” or “Invalid user ID”

  • Wrong INSTAGRAM_BUSINESS_ACCOUNT_ID

  • Verify with query: me/accounts?fields=instagram_business_account in Graph API Explorer

  • Make sure your Instagram account is linked to your Facebook Page

No posts showing

  • Verify your Instagram Business Account has posts

  • Test the /media endpoint in Graph API Explorer

  • Check that you’ve granted all required permissions

CORS errors on images

  • Make sure you’re using the proxyImg() function for all Instagram images

  • Check that the proxy route is working: /api/proxy-image?url=...

  • Verify allowed domains in the proxy route match Instagram’s CDN

Rate limit errors

  • You’re making too many API calls

  • Increase CACHE_TTL from 10 minutes to 15-20 minutes

  • Check console logs for cache hits vs misses

Cache not working

  • Look for console logs showing “Cache HIT” or “Cache MISS.”

  • Verify the cache key is consistent

  • Remember: cache clears when the dev server restarts

Production Considerations

Access Token Management

Short-lived tokens expire in 1 hour. For production:

  1. Generate long-lived tokens (60 days) in Access Token Debugger

  2. Implement token refresh logic to automatically exchange expiring tokens

  3. Store tokens securely using environment variables or secret management

  4. Monitor token expiration and alert before expiry

Example token refresh endpoint:

const response = await fetch(
  `https://graph.facebook.com/v25.0/oauth/access_token?` +
  `grant_type=fb_exchange_token&` +
  `client_id=${process.env.FACEBOOK_APP_ID}&` +
  `client_secret=${process.env.FACEBOOK_APP_SECRET}&` +
  `fb_exchange_token=${oldToken}`
);

Rate Limiting

Meta’s API has rate limits based on your app’s tier:

  • Development mode: ~200 calls/hour

  • Live mode: Varies by app usage and tier

The two-layer cache significantly reduces API calls. Monitor your usage in the Meta Developer Dashboard.

Caching Strategy

For high-traffic sites, consider:

Redis or Memcached for distributed caching across serverless instances:

Image Optimization

Use Next.js Image component for automatic optimization:

import Image from 'next/image';

<Image
  src={proxyImg(post.thumbnail)}
  alt={post.caption || 'Instagram post'}
  fill
  className="object-cover"
  sizes="(max-width: 768px) 33vw, 25vw"
/>

Environment Variables

Make sure to set them in your deployment platform:

Vercel:

FACEBOOK_PAGE_ACCESS_TOKENINSTAGRAM_BUSINESS_ACCOUNT_ID

Netlify:

FACEBOOK_PAGE_ACCESS_TOKEN "your_token"
INSTAGRAM_BUSINESS_ACCOUNT_ID "your_id"

Security Checklist

  • Never commit .env.local to version control

  • Use long-lived tokens for production (60 days)

  • Implement token refresh before expiration

  • Whitelist only Instagram CDN domains in image proxy

  • Enable rate limiting on API routes

  • Add error logging and monitoring

  • Use HTTPS in production

  • Validate all user inputs

Wrapping Up

You just built a production-ready Instagram profile viewer with:

  • Official Meta Facebook Graph API integration

  • Secure authentication with access tokens

  • Two-layer caching system (in-memory + Next.js)

  • IP-based rate limiting

  • Loading states and error handling

  • Responsive design with modals

  • Type-safe TypeScript throughout

  • Secure image proxy with domain whitelisting

This architecture works for any authenticated API. The pattern is always the same:

  1. Set up API credentials securely

  2. Server route handles external API calls with authentication

  3. Caching protects rate limits and improves performance

  4. Rate limiting protects your server and API quota

  5. Client component handles UI and state

  6. Error handling makes failures graceful

In case you don’t want to create the component from scratch, you can consider using ready-to-use components. For example, Shadcn Studio.

Special Mention: Shadcn Studio

shadcn studio animation library

Shadcn Studio goes beyond animations by providing animated blocks and sections built on top of Shadcn UI. It’s perfect for teams that want speed without compromising design quality.

Key Features:

  • Pre-animated components, blocks, and layouts

  • Built specifically for Shadcn UI

  • Consistent motion patterns

  • Great for marketing and landing pages

Furthermore, Animated variants with motion add smooth, modern animations to your components, enhancing user experiences with minimal effort.

Best For: Startups, agencies, and rapid MVP development.

Additionally, it comes with the following features:

  • Open-source: Dive into a growing, community-driven collection of copy-and-paste shadcn components, Shadcn blocks, and templates.

  • Component & Blocks variants: Access a diverse collection of customizable shadcn ui blocks and component variants to quickly build and style your UI with ease.

  • Landing pages & Dashboards: Explore 20+ premium & free Shadcn templates for dashboards, landing pages & more. Fully customizable & easy to use.

  • shadcn/ui for Figma: Speed up your workflow with Shadcn Figma UI Kit with components, blocks & templates – a full design library inspired by shadcn/ui.

  • Powerful theme generator: Customize your UI instantly with Shadcn Theme Generator. Preview changes in real time and create consistent, on-brand designs faster.

  • shadcn/studio MCP: Integrate shadcn/studio MCP Server directly into your favorite IDE and craft stunning shadcn/ui Components, Blocks, and Pages inspired by shadcn/studio.

  • Shadcn Figma To Code Plugin: Convert your Figma designs into production-ready code instantly with the Shadcn Figma Plugin.

Resources & Documentation

GitHub: shadcn-instagram

Next.js

Meta Graph API

Shadcn/ui