Building a Minimalist Blog with Bluesky and Next.js
I recently built a new personal blog platform that combines the decentralized nature of the Bluesky social network with the modern developer experience of Next.js. Here's how I put it together and what I learned along the way.
The Stack
The blog is built with:
- Next.js 14 for the framework and routing
- Tailwind CSS for styling
- bright for syntax highlighting
- react-markdown for rendering markdown content
- Bluesky API for content storage and retrieval
The Concept
I wanted to create a minimalist, terminal-inspired blog that would:
- Store all content on the Bluesky network using their custom protocol
- Render content statically for optimal performance
- Support full markdown with code highlighting
- Have a clean, distraction-free reading experience
- Include dark mode support
Key Features
Terminal-Inspired Design
The UI is built around a terminal window metaphor, complete with:
- Window controls (red, yellow, green dots)
- Title bar showing current "directory"
- Monospace font (Geist Mono)
- Command-line inspired navigation
export function Terminal({ children, title = "terminal" }: TerminalProps) {
return (
<div className="w-full max-w-4xl mx-auto overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800 shadow-lg">
{/* Title bar */}
<div className="relative flex items-center justify-between px-4 py-2 bg-gray-100 dark:bg-gray-800/50">
{/* ... */}
</div>
<div className="p-6 bg-white dark:bg-gray-900 min-h-[200px]">
{children}
</div>
</div>
);
}
Content Storage
All blog posts are stored as custom records on the Bluesky network using their com.whtwnd.blog.entry
record type. This means the content is:
- Decentralized
- Portable
- Accessible via the AT Protocol
- Versioned
The API integration is handled through a simple client wrapper:
export async function getPosts(options: GetPostsOptions = {}) {
const posts = await bsky.get("com.atproto.repo.listRecords", {
params: {
repo: process.env.ACCOUNT_DID!,
collection: "com.whtwnd.blog.entry",
limit: options.limit,
cursor: options.cursor,
reverse: options.reverse,
},
});
// ...
}
Markdown Rendering
The blog supports full GitHub-flavored markdown with some nice extras:
- Syntax highlighting for code blocks
- Custom components for headings, links, and blockquotes
- Footnotes support
- Table formatting
- Custom prose styles via Tailwind
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
className="prose dark:prose-invert max-w-none"
components={{
code({ node, inline, className, children, ...props }) {
// Custom code block rendering
},
// Other custom components...
}}
>
{post.value.content}
</Markdown>
Dark Mode
The site includes a fully-featured dark mode that:
- Respects system preferences
- Can be toggled manually
- Persists user choice
- Transitions smoothly between modes
This is implemented using next-themes
and custom Tailwind styles:
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
{theme === "dark" ? <Sun /> : <Moon />}
</button>
);
}
Lessons Learned
Building this project taught me several valuable lessons:
-
AT Protocol Integration: Working with Bluesky's API required understanding their custom protocols and record types. While different from traditional REST APIs, it offers interesting possibilities for decentralized content.
-
Static Generation: Next.js's static generation capabilities work great with decentralized content, allowing for fast page loads while still maintaining dynamic content updates.
-
Design Systems: Creating a cohesive design system using Tailwind requires careful planning of components and consistent styling patterns.
Future Improvements
Some features I'm considering adding:
- [ ] RSS feed generation
- [ ] Image optimization and lazy loading
- [ ] Comment system using Bluesky replies
- [ ] Improved search functionality
- [ ] Post series/categorization