Vue.js Instagram Feed Tutorial

Create Instagram feeds in Vue 3 with Feedframer's API: a basic component, a reusable composable with filtering and "load more", Nuxt 3 and Pinia examples, and tests.

Overview

This guide uses the Composition API with <script setup>. Every example calls GET /api/v1/me, which returns your Instagram profile and your posts in one response. The REST API reference lists every parameter.

Prerequisites

  • Vue 3.3 or later (the composable uses toValue)
  • A Feedframer account with a connected Instagram account
  • The account's API key, shown on its card on the Dashboard

About your API key

A component that runs in the browser sends your API key with each request, so visitors can see it in the network tab. An environment variable such as VITE_FEEDFRAMER_API_KEY keeps the key out of your repository, but the build still copies it into the JavaScript bundle.

The key is read-only and only reads posts that are already public on Instagram, so this is usually fine. If a key leaks and you want to stop it working, press Regenerate on the account's card on the Dashboard. The old key stops working immediately.

To keep the key private, fetch on the server instead. The Nuxt example below does that.

Quick start

1. Basic component

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

const apiKey = 'YOUR_API_KEY';

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

// VIDEO posts: mediaUrl is usually an .mp4, which an <img> can't show.
// Premium posts carry resized copies in `sizes`.
function imageFor(post) {
  if (post.sizes) return post.sizes['768'];
  return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}

onMounted(async () => {
  try {
    const response = await fetch(`https://feedframer.com/api/v1/me?api_key=${apiKey}`);
    const body = await response.json();

    if (!response.ok) {
      throw new Error(body.errors?.[0]?.detail ?? `HTTP ${response.status}`);
    }

    posts.value = body.posts;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
});
</script>

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

    <div v-else-if="error" class="text-red-600">
      Could not load Instagram posts: {{ error }}
    </div>

    <div v-else class="grid grid-cols-3 gap-4">
      <template v-for="post in posts" :key="post.id">
        <a
          v-if="imageFor(post)"
          :href="post.permalink"
          target="_blank"
          rel="noopener noreferrer"
          class="relative aspect-square"
        >
          <img
            :src="imageFor(post)"
            :alt="post.altText || post.caption?.slice(0, 120) || 'Instagram post'"
            loading="lazy"
            class="h-full w-full rounded object-cover"
          />
        </a>
      </template>
    </div>
  </div>
</template>

Without page[size], the API returns your plan's limit: the 6 most recent posts on Free, or up to 100 on Premium.

Composable pattern

This composable handles the request, filtering by type, and "load more" with cursors. It returns the profile fields as well as the posts.

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

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

export class FeedframerError extends Error {
  constructor(status, errors = []) {
    const first = errors[0] ?? {};
    super(first.detail ?? `Feedframer request failed with HTTP ${status}`);
    this.name = 'FeedframerError';
    this.status = status;
    this.code = first.code ?? null;
  }
}

export async function fetchFeed(apiKey, params = {}) {
  const query = new URLSearchParams({ api_key: apiKey });

  for (const [name, value] of Object.entries(params)) {
    if (value !== undefined && value !== null && value !== '') {
      query.set(name, value);
    }
  }

  const response = await fetch(`${BASE_URL}/api/v1/me?${query}`);
  const body = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new FeedframerError(response.status, body.errors);
  }

  return body;
}

// apiKey, options.type and options.pageSize can each be a plain value,
// a ref, a computed or a getter function.
export function useFeedframerPosts(apiKey, options = {}) {
  const profile = ref(null);
  const posts = ref([]);
  const loading = ref(false);
  const error = ref(null);
  const hasMore = ref(false);

  let cursor = null;
  let latestRequest = 0;

  async function load(append) {
    const requestId = ++latestRequest;
    loading.value = true;
    error.value = null;

    try {
      const { posts: page, pagination, ...account } = await fetchFeed(toValue(apiKey), {
        'filter[type]': toValue(options.type),
        'page[size]': toValue(options.pageSize),
        'page[cursor]': append ? cursor : null,
      });

      // Ignore responses for a key or filter that has since changed.
      if (requestId !== latestRequest) return;

      profile.value = account;
      posts.value = append ? [...posts.value, ...page] : page;
      cursor = pagination.nextCursor;
      hasMore.value = pagination.hasMore;
    } catch (err) {
      if (requestId !== latestRequest) return;
      error.value = err;

      // A failed first page shouldn't leave the previous feed on screen.
      if (!append) {
        posts.value = [];
        hasMore.value = false;
      }
    } finally {
      if (requestId === latestRequest) loading.value = false;
    }
  }

  // Watch the values, not the ref objects, so a change to any of them
  // starts again from the first page.
  watch(
    () => [toValue(apiKey), toValue(options.type), toValue(options.pageSize)],
    () => load(false),
    { immediate: true }
  );

  function loadMore() {
    if (hasMore.value && !loading.value) load(true);
  }

  return { profile, posts, loading, error, hasMore, loadMore, refresh: () => load(false) };
}

error holds a FeedframerError with status and code (for example INVALID_API_KEY), or a plain Error if the network request failed.

Usage

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

const { profile, posts, loading, error } = useFeedframerPosts('YOUR_API_KEY', {
  type: 'IMAGE',
});
</script>

<template>
  <p v-if="loading && posts.length === 0">Loading...</p>
  <p v-else-if="error">Could not load posts: {{ error.message }}</p>
  <section v-else>
    <h2>@{{ profile.username }}</h2>
    <p>{{ posts.length }} image posts</p>
  </section>
</template>

type accepts IMAGE, VIDEO, CAROUSEL_ALBUM or REELS. REELS returns the same posts as VIDEO, because Instagram reports reels as videos. On the Free plan, the filter applies within your 6 most recent posts, so it can return fewer than 6, or none.

Advanced component with filtering

This gallery has filter buttons, a hover caption, a video badge and a "Load more" button. The activeType ref goes straight into the composable, which watches its value and sends it as filter[type]. A computed works the same way. Vue renders {{ post.caption }} as text, so captions can't inject HTML. Don't use v-html for captions.

<script setup>
import { ref } from 'vue';
import { useFeedframerPosts } from '@/composables/useFeedframerPosts';

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

const filters = [
  { label: 'All', type: '' },
  { label: 'Images', type: 'IMAGE' },
  { label: 'Videos', type: 'VIDEO' },
  { label: 'Carousels', type: 'CAROUSEL_ALBUM' },
];

const activeType = ref('');

// 12 per page suits Premium. Free keys always get their 6 latest posts, one page.
const { posts, loading, error, hasMore, loadMore } = useFeedframerPosts(() => props.apiKey, {
  type: activeType,
  pageSize: 12,
});

function imageFor(post) {
  if (post.sizes) return post.sizes['768'];
  return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}
</script>

<template>
  <div>
    <!-- Filter buttons -->
    <div class="mb-6 flex gap-2">
      <button
        v-for="filter in filters"
        :key="filter.label"
        type="button"
        :class="[
          'rounded px-4 py-2',
          activeType === filter.type ? 'bg-blue-500 text-white' : 'bg-gray-200',
        ]"
        @click="activeType = filter.type"
      >
        {{ filter.label }}
      </button>
    </div>

    <!-- Error state -->
    <div v-if="error" class="rounded border border-red-200 bg-red-50 px-4 py-3 text-red-700">
      Could not load Instagram posts: {{ error.message }}
    </div>

    <!-- Grid -->
    <div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
      <template v-for="post in posts" :key="post.id">
        <a
          v-if="imageFor(post)"
          :href="post.permalink"
          target="_blank"
          rel="noopener noreferrer"
          class="group relative aspect-square overflow-hidden rounded-lg"
        >
          <img
            :src="imageFor(post)"
            :alt="post.altText || post.caption?.slice(0, 120) || 'Instagram post'"
            loading="lazy"
            class="h-full w-full object-cover transition-transform group-hover:scale-105"
          />

          <span
            v-if="post.mediaType === 'VIDEO'"
            class="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-xs text-white"
          >
            Video
          </span>

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

    <p v-if="!loading && !error && posts.length === 0">No posts match this filter.</p>
    <p v-if="loading" class="mt-6 text-center">Loading...</p>

    <button
      v-if="hasMore && !loading"
      type="button"
      class="mt-6 rounded bg-gray-900 px-4 py-2 text-white"
      @click="loadMore"
    >
      Load more
    </button>
  </div>
</template>

hasMore is always false for Free keys, so the "Load more" button never shows for them. See Pagination for how cursors work.

Nuxt 3 integration

In Nuxt, a server route can call Feedframer so the API key never reaches the browser. Keep the key in private runtime config (not under public):

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    feedframerApiKey: '', // set by the NUXT_FEEDFRAMER_API_KEY environment variable
  },
});
# .env
NUXT_FEEDFRAMER_API_KEY=your_api_key_here

The server route adds the key and passes the response through, including Feedframer's error status and body:

// server/api/instagram.get.ts
export default defineEventHandler(async (event) => {
  const { feedframerApiKey } = useRuntimeConfig(event);
  const { type, cursor } = getQuery(event);

  const response = await $fetch.raw('https://feedframer.com/api/v1/me', {
    query: {
      api_key: feedframerApiKey,
      'filter[type]': type || undefined,
      'page[cursor]': cursor || undefined,
    },
    ignoreResponseError: true,
  });

  setResponseStatus(event, response.status);
  return response._data;
});

The page calls your route with useFetch. The response keys are the same as the API's: posts, pagination, and the profile fields.

<script setup>
const type = ref('');

// useFetch refetches when a ref in `query` changes.
const { data, error } = await useFetch('/api/instagram', {
  query: { type },
});

function imageFor(post) {
  if (post.sizes) return post.sizes['768'];
  return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}
</script>

<template>
  <div v-if="error">Could not load Instagram posts.</div>

  <div v-else-if="data">
    <h2>@{{ data.username }}</h2>

    <select v-model="type">
      <option value="">All posts</option>
      <option value="IMAGE">Images</option>
      <option value="VIDEO">Videos</option>
      <option value="CAROUSEL_ALBUM">Carousels</option>
    </select>

    <div class="grid grid-cols-3 gap-4">
      <template v-for="post in data.posts" :key="post.id">
        <a v-if="imageFor(post)" :href="post.permalink" target="_blank" rel="noopener noreferrer">
          <img :src="imageFor(post)" :alt="post.altText || 'Instagram post'" loading="lazy" />
        </a>
      </template>
    </div>
  </div>
</template>

Pinia state management

A Pinia store can keep feeds loaded across pages. This one caches each feed under the API key and the filter, so a different key or filter never gets another feed's posts. It reuses fetchFeed from the composable above.

// stores/instagram.js
import { defineStore } from 'pinia';
import { fetchFeed } from '@/composables/useFeedframerPosts';

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

  getters: {
    feed: (state) => (apiKey, type = '') => state.feeds[`${apiKey}:${type}`] ?? null,
  },

  actions: {
    async fetchPosts(apiKey, { type = '', force = false } = {}) {
      const cacheKey = `${apiKey}:${type}`;

      if (this.feeds[cacheKey] && !force) {
        return this.feeds[cacheKey];
      }

      this.loading = true;
      this.error = null;

      try {
        const data = await fetchFeed(apiKey, { 'filter[type]': type });
        this.feeds[cacheKey] = data;
        return data;
      } catch (err) {
        this.error = err;
        return null;
      } finally {
        this.loading = false;
      }
    },
  },
});

Usage with Pinia

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

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

const feed = computed(() => instagram.feed(apiKey));

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

function imageFor(post) {
  if (post.sizes) return post.sizes['768'];
  return post.mediaType === 'VIDEO' ? post.thumbnailUrl : post.mediaUrl;
}
</script>

<template>
  <div v-if="instagram.loading && !feed">Loading...</div>
  <div v-else-if="instagram.error">Could not load posts: {{ instagram.error.message }}</div>

  <div v-else-if="feed" class="grid grid-cols-3 gap-4">
    <template v-for="post in feed.posts" :key="post.id">
      <img v-if="imageFor(post)" :src="imageFor(post)" :alt="post.altText || 'Instagram post'" loading="lazy" />
    </template>
  </div>
</template>

Component architecture

Presentational component

InstagramPost shows one post. It uses the thumbnail for videos and a responsive srcset when sizes is present (Premium).

<!-- components/InstagramPost.vue -->
<script setup>
import { computed } from 'vue';

const props = defineProps({
  post: {
    type: Object,
    required: true,
  },
});

const src = computed(() => {
  if (props.post.sizes) return props.post.sizes['768'];
  return props.post.mediaType === 'VIDEO' ? props.post.thumbnailUrl : props.post.mediaUrl;
});

const srcset = computed(() => {
  if (!props.post.sizes) return undefined;
  return Object.entries(props.post.sizes)
    .map(([width, url]) => `${url} ${width}w`)
    .join(', ');
});
</script>

<template>
  <a
    v-if="src"
    :href="post.permalink"
    target="_blank"
    rel="noopener noreferrer"
    class="group relative block aspect-square overflow-hidden rounded-lg"
  >
    <img
      :src="src"
      :srcset="srcset"
      sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 100vw"
      :alt="post.altText || post.caption?.slice(0, 120) || 'Instagram post'"
      loading="lazy"
      class="h-full w-full object-cover transition-transform group-hover:scale-105"
    />

    <span
      v-if="post.mediaType === 'VIDEO'"
      class="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-xs text-white"
    >
      Video
    </span>

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

To play a video instead of showing its thumbnail, use <video :src="post.mediaUrl" :poster="post.thumbnailUrl" controls playsinline preload="none">.

Container component

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

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

const { posts, loading, error, hasMore, loadMore } = useFeedframerPosts(() => props.apiKey, {
  type: () => props.type,
});
</script>

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

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

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

    <button v-if="hasMore && !loading" type="button" @click="loadMore">Load more</button>
  </div>
</template>

Without a page size, Premium keys get up to 100 posts in the first page, so "Load more" only appears once you have more than 100 posts.

Error handling

Every error response from the API has the same JSON:API body, with a code on each error:

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

The composable turns that into a FeedframerError. Switch on error.code to show visitors a useful message:

import { FeedframerError } from '@/composables/useFeedframerPosts';

export function errorMessage(error) {
  if (!(error instanceof FeedframerError)) {
    // fetch itself failed: offline, DNS, or the request was blocked.
    return 'Could not load Instagram posts. Check your connection.';
  }

  switch (error.code) {
    case 'MISSING_API_KEY':
    case 'INVALID_API_KEY':
      // The key is wrong, was regenerated, or its account was disconnected.
      return 'This Instagram feed is not set up correctly.';
    case 'VALIDATION_ERROR':
      return `Invalid request: ${error.message}`;
    default:
      // INTERNAL_SERVER_ERROR, or a response without an error body.
      return 'Instagram posts are unavailable right now. Please try again later.';
  }
}

The Errors page lists every code.

Testing with Vitest

This test uses Vue Test Utils with Vitest and the jsdom environment. It mocks fetch with a response shaped like the real API and checks the basic component from the quick start.

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

const feed = {
  username: 'your_username',
  name: 'Your Name',
  biography: null,
  website: null,
  profilePictureUrl: null,
  followersCount: 1234,
  followsCount: 567,
  mediaCount: 89,
  posts: [
    {
      id: '18123456789012345',
      caption: 'Sunset over the harbour',
      altText: null,
      mediaType: 'IMAGE',
      mediaUrl: 'https://cdn.feedframer.com/your_username/18123456789012345_media.jpg',
      thumbnailUrl: null,
      permalink: 'https://www.instagram.com/p/ABC123def45/',
      timestamp: '2026-01-15T18:30:00+00:00',
      likeCount: 42,
      commentsCount: 3,
    },
  ],
  pagination: { nextCursor: null, prevCursor: null, hasMore: false, perPage: 6 },
};

function mockFetch(status, body) {
  vi.stubGlobal(
    'fetch',
    vi.fn(() => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body) }))
  );
}

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('InstagramFeed', () => {
  it('renders posts', async () => {
    mockFetch(200, feed);
    const wrapper = mount(InstagramFeed);

    await flushPromises();

    const img = wrapper.find('img');
    expect(img.attributes('alt')).toBe('Sunset over the harbour');
    expect(img.attributes('src')).toBe(feed.posts[0].mediaUrl);
  });

  it('shows the API error detail', async () => {
    mockFetch(401, {
      errors: [
        {
          status: '401',
          code: 'INVALID_API_KEY',
          title: 'Authentication Failed',
          detail: 'The provided API key is invalid.',
        },
      ],
    });
    const wrapper = mount(InstagramFeed);

    await flushPromises();

    expect(wrapper.text()).toContain('The provided API key is invalid.');
  });
});

Performance

  • Lazy-load images with loading="lazy", as the examples above do.
  • Use sizes on Premium. The four widths (320, 768, 960 and 1200 pixels) let the browser pick a small file on small screens. InstagramPost above builds the srcset. Add format=webp to the request to get WebP URLs in sizes instead of JPEG.
  • Don't add a cache-busting parameter. Successful responses are cached at Feedframer's edge for an hour, keyed on the full URL, and that cache is cleared when new posts arrive. A timestamp in the URL skips the cache every time.

TypeScript support

These types match the /api/v1/me response:

// types/feedframer.ts
export type MediaType = 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';

export type Sizes = Record<'320' | '768' | '960' | '1200', string>;

export interface Media {
  id: string; // Instagram's post id
  caption: string | null;
  altText: string | null;
  mediaType: MediaType;
  mediaUrl: string; // an .mp4 for most VIDEO posts
  thumbnailUrl: string | null; // set for VIDEO posts
  permalink: string;
  timestamp: string; // ISO 8601
  likeCount: number | null;
  commentsCount: number | null;
  sizes?: Sizes | null; // Premium only; omitted otherwise
}

export interface Comment {
  id: string;
  text: string;
  timestamp: string;
  like_count: number;
}

export interface Post extends Media {
  children?: Media[]; // with include[]=children
  comments?: Comment[]; // with include[]=comments
}

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

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

Best practices

  1. Use the composable to keep request, filter and pagination logic in one place.
  2. Keep the key out of your repository with an environment variable, or on the server with a Nuxt server route. Use Regenerate if it leaks.
  3. Show thumbnailUrl for video posts in an <img>, or use a <video> element for mediaUrl.
  4. Render captions with {{ }}, never v-html.
  5. Check hasMore before offering another page. Free keys only ever get one page.
  6. Handle errors by switching on error.code.

Additional resources