A deep dive into creating a dynamic social media feed component that fetches and displays Bluesky posts with fallback support for static hosting.
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.
Building a social media feed component presents several challenges:
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
})
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.
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
})
I chose to use a static JSON file instead of direct API calls for several reasons:
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.
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.
The component handles four distinct UI states:
<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.
<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.
The main content display includes:
<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>
The component includes several utility functions that enhance the user experience:
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").
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.
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.
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:
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:
Several optimizations ensure fast loading and smooth interactions:
loading="lazy"
attributesserver: false
prevents hydration mismatchesv-if
/v-else
conditionsThe 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 displaylimit
: Control how many posts to showBuilding this component taught me several valuable lessons:
Potential improvements for the component include:
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.