Building a Bluesky Feed Component with Vue 3 and Nuxt

A deep dive into creating a dynamic social media feed component that fetches and displays Bluesky posts with fallback support for static hosting.

Building a Bluesky Feed Component with Vue 3 and Nuxt

Social media integration has become a staple of modern web development, and with the rise of decentralized platforms like Bluesky, developers need flexible solutions to display social content. In this post, I'll break down how I built a reusable Vue 3 component that fetches and displays Bluesky posts with elegant fallbacks for static hosting.

blueskyfeed.png

The Challenge

Building a social media feed component presents several challenges:

  1. API Integration: Connecting to external social media APIs
  2. Data Handling: Managing different data states (loading, error, success)
  3. Static Hosting: Supporting platforms like GitHub Pages that don't allow server-side API calls
  4. User Experience: Providing smooth loading states and error handling
  5. Performance: Optimizing for fast load times and responsiveness

Component Architecture Overview

My BlueskyFeed component follows a hybrid approach that can work both with live API data and pre-generated static data:

<script lang="ts" setup>
// Component props for flexibility
interface Props {
  handle?: string
  limit?: number
}

const props = withDefaults(defineProps<Props>(), {
  handle: 'louis-castillo.bsky.social',
  limit: 6
})

TypeScript Interfaces for Type Safety

First, I defined comprehensive TypeScript interfaces to ensure type safety throughout the component:

interface BlueskyPost {
  uri: string
  cid: string
  author: {
    did: string
    handle: string
    displayName: string
    avatar?: string
  }
  record: {
    text: string
    createdAt: string
    reply?: any
  }
  embed?: {
    images?: Array<{
      thumb: string
      fullsize: string
      alt?: string
    }>
  }
  replyCount?: number
  repostCount?: number
  likeCount?: number
}

This interface captures all the essential data from Bluesky's AT Protocol, ensuring our component can handle the complete range of post types and metadata.

Data Fetching Strategy

The component uses Nuxt's useLazyFetch composable to retrieve data from a static JSON file:

const { data, pending: fetchPending, error: fetchError, refresh } = await useLazyFetch<{
  feed: BlueskyPost[]
  lastUpdated: string
  source: string
}>('/bluesky-feed.json', {
  default: () => ({ feed: [], lastUpdated: '', source: 'none' }),
  server: false // Only fetch on client side for GitHub Pages compatibility
})

Why Static JSON?

I chose to use a static JSON file instead of direct API calls for several reasons:

  1. GitHub Pages Compatibility: Static hosting platforms can't make server-side API calls
  2. Performance: Pre-generated data loads faster than API calls
  3. Reliability: No dependency on external API availability during page load
  4. Caching: Static files can be cached effectively by CDNs

Data Processing and Filtering

The component includes intelligent filtering to ensure quality content:

watch(data, (newData) => {
  if (newData?.feed) {
    // Filter out any replies that might have slipped through
    const filteredPosts = newData.feed.filter(post => {
      // Exclude posts that are replies (have a reply field in the record)
      return !post.record.reply
    })
    posts.value = filteredPosts.slice(0, props.limit)
    lastUpdated.value = newData.lastUpdated
    dataSource.value = newData.source
  }
})

This filtering ensures that only original posts (not replies) are displayed, maintaining a clean feed focused on primary content.

State Management with Reactive References

The component manages several reactive states to provide a smooth user experience:

const posts = ref<BlueskyPost[]>([])
const pending = ref(true)
const error = ref<string | null>(null)
const lastUpdated = ref<string>('')
const dataSource = ref<string>('')

These reactive references automatically update the UI when data changes, providing real-time feedback to users.

UI States and User Experience

The component handles four distinct UI states:

1. Loading State

<div v-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
  <div 
    v-for="i in 3" 
    :key="i"
    class="bg-white dark:bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-gray-800 p-6 animate-pulse"
  >
    <!-- Skeleton loading UI -->
  </div>
</div>

The loading state uses skeleton screens that match the layout of actual posts, providing visual continuity during data fetching.

2. Error State

<div v-else-if="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-6 text-center">
  <svg class="w-12 h-12 text-red-500 mx-auto mb-4"><!-- Error icon --></svg>
  <h3 class="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">Unable to load Bluesky feed</h3>
  <p class="text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
  <button @click="refresh()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
    Try Again
  </button>
</div>

The error state provides clear feedback and a retry mechanism, maintaining user agency when things go wrong.

3. Success State

The main content display includes:

  • Responsive Grid Layout: Adapts from single column on mobile to three columns on large screens
  • Post Cards: Each post is displayed in an attractive card with hover effects
  • Author Information: Profile pictures with fallback initials, names, and handles
  • Rich Content: Support for text content and embedded images
  • Engagement Metrics: Like, reply, and repost counts with appropriate icons
  • External Links: Direct links to view posts on Bluesky

4. Empty State

<div v-else class="text-center py-12">
  <svg class="w-16 h-16 text-gray-400 mx-auto mb-4"><!-- Message icon --></svg>
  <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">No posts found</h3>
  <p class="text-gray-600 dark:text-gray-400">Check back later for new posts!</p>
</div>

Utility Functions

The component includes several utility functions that enhance the user experience:

Date Formatting

const formatDate = (dateString: string): string => {
  const date = new Date(dateString)
  const now = new Date()
  const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60))
  
  if (diffInHours < 1) {
    const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60))
    return `${diffInMinutes}m ago`
  } else if (diffInHours < 24) {
    return `${diffInHours}h ago`
  } else {
    const diffInDays = Math.floor(diffInHours / 24)
    return `${diffInDays}d ago`
  }
}

This function converts timestamps into human-readable relative time formats (e.g., "2h ago", "3d ago").

Initial Generation

const getInitials = (name: string): string => {
  return name
    .split(' ')
    .map(word => word.charAt(0))
    .slice(0, 2)
    .join('')
    .toUpperCase()
}

Creates fallback initials when profile pictures aren't available, ensuring consistent visual presentation.

Post URL Generation

const getPostUrl = (post: BlueskyPost): string => {
  const postId = post.uri.split('/').pop()
  return `https://bsky.app/profile/${post.author.handle}/post/${postId}`
}

Generates direct links to posts on the Bluesky platform.

Data Generation Pipeline

The component works in conjunction with a Node.js script that generates the static JSON data:

// scripts/generate-bluesky-data.mjs
const response = await agent.getAuthorFeed({
  actor: handle,
  limit: 20,
  filter: 'posts_no_replies'
});

const filteredPosts = response.data.feed.filter((item) => {
  return !item.post.record.reply;
});

This script:

  1. Connects to the Bluesky API using official AT Protocol libraries
  2. Fetches recent posts with reply filtering
  3. Processes and formats the data
  4. Saves it as a static JSON file for the component to consume

Styling and Theming

The component uses Tailwind CSS with comprehensive dark mode support:

<article class="group bg-white dark:bg-gray-900/50 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-300 overflow-hidden hover:transform hover:scale-105">

Key styling features:

  • Blur Effects: Subtle backdrop blur and transparency
  • Smooth Transitions: Hover effects and state changes
  • Responsive Design: Mobile-first approach with progressive enhancement
  • Dark Mode: Complete theme switching support

Performance Optimizations

Several optimizations ensure fast loading and smooth interactions:

  1. Lazy Loading: Images use loading="lazy" attributes
  2. Client-Side Only: server: false prevents hydration mismatches
  3. Efficient Filtering: Data processing happens in reactive watchers
  4. Minimal Re-renders: Strategic use of v-if/v-else conditions

Usage and Integration

The component is designed for easy integration:

<template>
  <BlueskyFeed 
    handle="your-handle.bsky.social" 
    :limit="6" 
  />
</template>

Props provide flexibility:

  • handle: Specify which Bluesky user's posts to display
  • limit: Control how many posts to show

Lessons Learned

Building this component taught me several valuable lessons:

  1. Hybrid Approaches Work: Combining static generation with dynamic capabilities provides the best of both worlds
  2. Error States Matter: Users appreciate clear feedback when things go wrong
  3. Performance First: Static data often outperforms real-time API calls for user experience
  4. Type Safety Pays: Comprehensive TypeScript interfaces prevent runtime errors
  5. Progressive Enhancement: Building for the constraints of static hosting leads to more robust solutions

Future Enhancements

Potential improvements for the component include:

  • Real-time Updates: WebSocket integration for live post updates
  • Infinite Scrolling: Load more posts as users scroll
  • Caching Strategies: Smart cache invalidation for fresher content

Conclusion

The BlueskyFeed component demonstrates how modern web development can balance performance, user experience, and technical constraints. By leveraging Vue 3's Composition API, Nuxt's powerful data fetching, and thoughtful architecture decisions, we created a component that's both powerful and maintainable.

The hybrid approach of using static data generation with fallbacks ensures the component works reliably across different hosting environments while maintaining excellent performance. This pattern can be applied to other social media integrations and external API dependencies.

Whether you're building a personal portfolio or a larger application, this component architecture provides a solid foundation for social media integration that prioritizes user experience and developer productivity.


Thanks for reading! 🚀

Share: