Vue.js Instagram Feed Tutorial

Create Instagram feeds in Vue.js with Feedframer's API. Complete tutorial with composition API, reactive data, and Nuxt.js integration examples.

Overview

This guide demonstrates how to integrate Feedframer's Instagram API into a Vue 3 application using the Composition API. Perfect for Vue 3, Nuxt 3, and modern Vue projects.

Prerequisites

  • Vue 3.0+ with Composition API
  • Feedframer account with connected Instagram account
  • API key from Feedframer dashboard

Quick Start

1. Basic Component

Create a simple Instagram feed component:

<script setup>
import { ref, onMounted } from 'vue';

const posts = ref([]);
const loading = ref(true);
const error = ref(null);

const apiKey = 'YOUR_API_KEY';

onMounted(async () => {
  try {
    const response = await fetch(
      `https://feedframer.com/api/v1/me?api_key=${apiKey}&page[size]=12`
    );

    if (!response.ok) {
      throw new Error('Failed to fetch posts');
    }

    const data = await response.json();
    posts.value = data.posts;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <div>
    <div v-if="loading" class="text-center py-12">Loading...</div>

    <div v-else-if="error" class="text-red-600">
      Error: {{ error }}
    </div>

    <div v-else class="grid grid-cols-3 gap-4">
      <div
        v-for="post in posts"
        :key="post.id"
        class="relative aspect-square"
      >
        <img
          :src="post.mediaUrl"
          :alt="post.caption || 'Instagram post'"
          class="w-full h-full object-cover rounded"
        />
      </div>
    </div>
  </div>
</template>

Composable Pattern

Create a reusable composable for Instagram data:

// composables/useInstagramPosts.js
import { ref, watch } from 'vue';

export function useInstagramPosts(apiKey, options = {}) {
  const posts = ref([]);
  const loading = ref(true);
  const error = ref(null);

  const fetchPosts = async () => {
    loading.value = true;
    error.value = null;

    try {
      const params = new URLSearchParams({
        api_key: apiKey,
        'page[size]': options.pageSize || 12,
        sort: options.sort || '-publishedAt',
      });

      // Add filters
      if (options.filters) {
        Object.keys(options.filters).forEach((key) => {
          params.append(`filter[${key}]`, options.filters[key]);
        });
      }

      const response = await fetch(
        `https://feedframer.com/api/v1/me?${params}`
      );

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.errors[0]?.title || 'Failed to fetch posts');
      }

      const data = await response.json();
      posts.value = data.posts;
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  };

  // Watch for options changes
  watch(() => options, fetchPosts, { immediate: true, deep: true });

  return {
    posts,
    loading,
    error,
    refetch: fetchPosts,
  };
}

Usage

<script setup>
import { useInstagramPosts } from '@/composables/useInstagramPosts';

const apiKey = 'YOUR_API_KEY';

const { posts, loading, error } = useInstagramPosts(apiKey, {
  pageSize: 9,
  filters: { type: 'IMAGE' },
});
</script>

<template>
  <div class="grid grid-cols-3 gap-4">
    <div v-for="post in posts" :key="post.id">
      <img :src="post.mediaUrl" alt="" />
    </div>
  </div>
</template>

Advanced Component with Filtering

<script setup>
import { ref, computed } from 'vue';
import { useInstagramPosts } from '@/composables/useInstagramPosts';

const props = defineProps({
  apiKey: {
    type: String,
    required: true,
  },
});

const activeFilter = ref('all');

const filters = computed(() => {
  if (activeFilter.value === 'all') {
    return {};
  }
  return { type: activeFilter.value.toUpperCase() };
});

const { posts, loading, error } = useInstagramPosts(props.apiKey, {
  pageSize: 12,
  filters,
});
</script>

<template>
  <div>
    <!-- Filter Buttons -->
    <div class="flex gap-2 mb-6">
      <button
        @click="activeFilter = 'all'"
        :class="[
          'px-4 py-2 rounded',
          activeFilter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200'
        ]"
      >
        All
      </button>
      <button
        @click="activeFilter = 'image'"
        :class="[
          'px-4 py-2 rounded',
          activeFilter === 'image' ? 'bg-blue-500 text-white' : 'bg-gray-200'
        ]"
      >
        Images
      </button>
      <button
        @click="activeFilter = 'video'"
        :class="[
          'px-4 py-2 rounded',
          activeFilter === 'video' ? 'bg-blue-500 text-white' : 'bg-gray-200'
        ]"
      >
        Videos
      </button>
    </div>

    <!-- Loading State -->
    <div v-if="loading" class="flex justify-center items-center h-64">
      <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
    </div>

    <!-- Error State -->
    <div v-else-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
      Error: {{ error }}
    </div>

    <!-- Grid -->
    <div v-else class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
      <a
        v-for="post in posts"
        :key="post.id"
        :href="post.permalink"
        target="_blank"
        rel="noopener noreferrer"
        class="group relative aspect-square overflow-hidden rounded-lg"
      >
        <img
          :src="post.mediaUrl"
          :alt="post.caption || 'Instagram post'"
          class="w-full h-full object-cover transition-transform group-hover:scale-105"
        />

        <!-- Overlay on hover -->
        <div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-opacity flex items-center justify-center">
          <p class="text-white opacity-0 group-hover:opacity-100 px-4 text-center line-clamp-3">
            {{ post.caption }}
          </p>
        </div>
      </a>
    </div>
  </div>
</template>

Nuxt 3 Integration

Server-Side Rendering

<script setup>
const apiKey = useRuntimeConfig().public.feedframerApiKey;

const { data: posts, error } = await useFetch(
  `https://feedframer.com/api/v1/me?api_key=${apiKey}&page[size]=12`
);
</script>

<template>
  <div v-if="error">Error loading posts</div>

  <div v-else class="grid grid-cols-3 gap-4">
    <div v-for="post in posts.data" :key="post.id">
      <img :src="post.mediaUrl" alt="" />
    </div>
  </div>
</template>

Nuxt Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      feedframerApiKey: process.env.FEEDFRAMER_API_KEY,
    },
  },
});

Environment Variables

# .env
FEEDFRAMER_API_KEY=your_api_key_here

Pinia State Management

Manage Instagram data with Pinia:

// stores/instagram.js
import { defineStore } from 'pinia';

export const useInstagramStore = defineStore('instagram', {
  state: () => ({
    posts: [],
    loading: false,
    error: null,
  }),

  actions: {
    async fetchPosts(apiKey, options = {}) {
      this.loading = true;
      this.error = null;

      try {
        const params = new URLSearchParams({
          api_key: apiKey,
          'page[size]': options.pageSize || 12,
          sort: options.sort || '-publishedAt',
        });

        if (options.filters) {
          Object.keys(options.filters).forEach((key) => {
            params.append(`filter[${key}]`, options.filters[key]);
          });
        }

        const response = await fetch(
          `https://feedframer.com/api/v1/me?${params}`
        );

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const data = await response.json();
        this.posts = data.posts;
      } catch (err) {
        this.error = err.message;
      } finally {
        this.loading = false;
      }
    },
  },

  getters: {
    imagePosts: (state) => state.posts.filter((p) => p.mediaType === 'IMAGE'),
    videoPosts: (state) => state.posts.filter((p) => p.mediaType === 'VIDEO'),
  },
});

Usage with Pinia

<script setup>
import { useInstagramStore } from '@/stores/instagram';
import { onMounted } from 'vue';

const instagram = useInstagramStore();
const apiKey = 'YOUR_API_KEY';

onMounted(() => {
  instagram.fetchPosts(apiKey);
});
</script>

<template>
  <div v-if="instagram.loading">Loading...</div>

  <div v-else class="grid grid-cols-3 gap-4">
    <div v-for="post in instagram.posts" :key="post.id">
      <img :src="post.mediaUrl" alt="" />
    </div>
  </div>
</template>

Component Architecture

Presentational Component

<!-- components/InstagramPost.vue -->
<script setup>
defineProps({
  post: {
    type: Object,
    required: true,
  },
});
</script>

<template>
  <a
    :href="post.permalink"
    target="_blank"
    rel="noopener noreferrer"
    class="group block relative aspect-square overflow-hidden rounded-lg"
  >
    <img
      :src="post.mediaUrl"
      :alt="post.caption || 'Instagram post'"
      class="w-full h-full object-cover transition-transform group-hover:scale-105"
    />

    <div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
      <div class="absolute bottom-0 left-0 right-0 p-4">
        <p class="text-white text-sm line-clamp-2">
          {{ post.caption }}
        </p>
      </div>
    </div>
  </a>
</template>

Container Component

<!-- components/InstagramGallery.vue -->
<script setup>
import { useInstagramPosts } from '@/composables/useInstagramPosts';
import InstagramPost from './InstagramPost.vue';

const props = defineProps({
  apiKey: String,
  pageSize: {
    type: Number,
    default: 12,
  },
});

const { posts, loading, error } = useInstagramPosts(props.apiKey, {
  pageSize: props.pageSize,
});
</script>

<template>
  <div>
    <div v-if="loading" class="animate-pulse">
      <div class="grid grid-cols-3 gap-4">
        <div v-for="n in pageSize" :key="n" class="aspect-square bg-gray-200 rounded"></div>
      </div>
    </div>

    <div v-else-if="error" class="text-red-600">
      {{ error }}
    </div>

    <div v-else class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
      <InstagramPost v-for="post in posts" :key="post.id" :post="post" />
    </div>
  </div>
</template>

Testing with Vitest

import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import InstagramFeed from './InstagramFeed.vue';

// Mock fetch
global.fetch = vi.fn(() =>
  Promise.resolve({
    ok: true,
    json: () => Promise.resolve({
      posts: [
        {
          id: '18123456789012345',
          mediaUrl: 'https://example.com/image.jpg',
          caption: 'Test post',
          mediaType: 'IMAGE',
          permalink: 'https://instagram.com/p/ABC123',
          timestamp: '2024-01-15T18:30:00+00:00'
        },
      ],
    }),
  })
);

describe('InstagramFeed', () => {
  it('renders posts', async () => {
    const wrapper = mount(InstagramFeed, {
      props: { apiKey: 'test-key' },
    });

    await wrapper.vm.$nextTick();
    await new Promise((resolve) => setTimeout(resolve, 100));

    expect(wrapper.text()).toContain('Test post');
  });
});

Performance Optimization

Lazy Loading with IntersectionObserver

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  src: String,
  alt: String,
});

const imgRef = ref(null);
const isLoaded = ref(false);
let observer;

onMounted(() => {
  observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        isLoaded.value = true;
        observer.unobserve(entry.target);
      }
    });
  });

  if (imgRef.value) {
    observer.observe(imgRef.value);
  }
});

onUnmounted(() => {
  if (observer) observer.disconnect();
});
</script>

<template>
  <img
    ref="imgRef"
    :src="isLoaded ? src : 'data:image/gif;base64,R0lGODlhAQABAAAAACw='"
    :alt="alt"
    class="w-full h-full object-cover"
  />
</template>

TypeScript Support

// types/instagram.ts
export interface InstagramPost {
  id: string;
  caption: string | null;
  mediaType: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM' | 'REELS';
  mediaUrl: string;
  thumbnailUrl: string | null;
  permalink: string;
  timestamp: string;
  children?: any[];
  comments?: any[];
}

export interface FeedframerResponse {
  username: string;
  name: string;
  biography: string | null;
  website: string | null;
  profilePictureUrl: string;
  followersCount: number;
  followsCount: number;
  mediaCount: number;
  posts: InstagramPost[];
  pagination: {
    nextCursor: string | null;
    prevCursor: string | null;
    hasMore: boolean;
    perPage: number;
  };
}

Best Practices

  1. Use composables - Keep logic reusable and testable
  2. Environment variables - Never hardcode API keys
  3. Error handling - Provide user-friendly error messages
  4. Loading states - Show feedback during data fetching
  5. Lazy loading - Optimize image loading for performance
  6. TypeScript - Use types for better developer experience

Additional Resources