Building a Streaming Interface for Developer Tools
Building a Streaming Interface for Developer Tools
I used to hoard bookmarks like a doomsday prepper. My browser was a graveyard of "read later" tabs, filled with starred GitHub repos and obscure Notion documents. I thought I was being productive. In reality, I was just building a digital junk drawer. I would remember seeing an incredible new vector database or a neat CLI tool, but when the time came to actually use it, the link was buried somewhere between a chicken parm recipe and my 2021 tax documents.
The rate at which people are shipping software right now is absurd. Every morning brings ten new AI wrappers, three JavaScript frameworks, along with a handful of SaaS tools. Platforms like Product Hunt and Hacker News are great, but they operate as linear feeds. They rely entirely on the present moment. If you miss Tuesday's feed, you miss Tuesday's tools.
We consume software discovery like a frantic news ticker. I realized we should be browsing it like a weekend movie night.
The cognitive load of evaluating a tool is high. We need an interface that lowers that barrier through visual curation. When I log into a streaming service, I see categories and curated collections, alongside giant hero images that set a mood, rather than a chronological list of movies. Why do we treat multi-million dollar developer tools with less visual respect than a B-tier sitcom?
The Architecture of a Visual Discovery Engine
I decided to build an OTT (Over-The-Top) experience for software. Think Netflix, but instead of sci-fi thrillers, it has categorized carousels of vector databases, frontend components, as well as deployment pipelines.
The architecture needed to be ridiculously fast and capable of semantic search. If I search for "fast postgres wrapper," I want the system to understand the meaning behind my query beyond exact keyword matches.
To pull this off without setting money on fire, the stack had to be lean and brutal. I went with Bun and Elysia for the backend API. If you haven't played with Elysia yet, it feels like Express went to the gym and learned proper TypeScript. For the database, Postgres 16 equipped with pgvector was the obvious choice. It handles both traditional relational data (users, collections) and vector embeddings for hybrid search. React 19 sits on the frontend, using its concurrent rendering capabilities to keep the carousels buttery smooth.
Backend Setup: Elysia and Drizzle
A lot of modern frameworks suffer from bloat. Elysia running on Bun strips that away. You get incredible throughput with very little boilerplate.
I define the schema using Drizzle ORM. Drizzle gives you absolute control over the SQL while maintaining type safety. Here is how I structured the core service table, paying special attention to the vector column for the embeddings.
// schema.ts
import { pgTable, text, timestamp, varchar, uuid, vector } from 'drizzle-orm/pg-core';
export const services = pgTable('services', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description').notNull(),
url: text('url').notNull(),
heroImageUrl: text('hero_image_url'),
// Storing the 1024-dimensional vector from Voyage AI
embedding: vector('embedding', { dimensions: 1024 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});Setting up the Elysia server to serve this data is shockingly concise.
// server.ts
import { Elysia, t } from 'elysia';
import { db } from './db';
import { services } from './schema';
import { desc } from 'drizzle-orm';
const app = new Elysia()
.get('/api/services/recent', async () => {
// Simple fetch for the "New Releases" carousel
return await db.select()
.from(services)
.orderBy(desc(services.createdAt))
.limit(10);
})
.listen(3000);
console.log(`🦊 Server running at ${app.server?.hostname}:${app.server?.port}`);The Brains: Hybrid Search with Reciprocal Rank Fusion
Vector search alone is sometimes too fuzzy. Keyword search alone is too rigid. The industry standard fix for this is Reciprocal Rank Fusion (RRF), which combines both scores.
I use Voyage AI for embeddings because their voyage-4-lite model is incredibly cost-effective for 1024-dimension vectors. When a user searches, I embed their query, hit Postgres for cosine similarity, run a text-match query, and merge the results.
The SQL looks intimidating, but it is just math. We rank the vector results and the text search results, then add the inverses of their ranks. The highest score wins. This means if a service perfectly matches the keyword but has a low semantic match, it still surfaces. If it has no keyword match but a perfect semantic match, it surfaces.
Here is how I implemented the RRF query using Drizzle's raw SQL capabilities:
// search.ts
import { sql } from 'drizzle-orm';
import { db } from './db';
import { getVoyageEmbedding } from './voyage';
export async function searchServices(query: string) {
// 1. Convert user query to vector
const queryVector = await getVoyageEmbedding(query);
const vectorString = `[${queryVector.join(',')}]`;
// 2. Perform Hybrid Search using RRF
const results = await db.execute(sql`
WITH semantic_search AS (
SELECT id, name, description, hero_image_url,
ROW_NUMBER() OVER (ORDER BY embedding <=> ${vectorString}::vector) as semantic_rank
FROM services
ORDER BY embedding <=> ${vectorString}::vector
LIMIT 50
),
keyword_search AS (
SELECT id, name, description, hero_image_url,
ROW_NUMBER() OVER (ORDER BY ts_rank_cd(to_tsvector('english', name || ' ' || description), plainto_tsquery('english', ${query})) DESC) as keyword_rank
FROM services
WHERE to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('english', ${query})
LIMIT 50
)
SELECT
COALESCE(s.id, k.id) as id,
COALESCE(s.name, k.name) as name,
COALESCE(s.description, k.description) as description,
COALESCE(s.hero_image_url, k.hero_image_url) as hero_image_url,
-- RRF Formula: 1 / (k + rank)
(COALESCE(1.0 / (60 + s.semantic_rank), 0.0)) +
(COALESCE(1.0 / (60 + k.keyword_rank), 0.0)) as rrf_score
FROM semantic_search s
FULL OUTER JOIN keyword_search k ON s.id = k.id
ORDER BY rrf_score DESC
LIMIT 20;
`);
return results.rows;
}The Ingestion Pipeline and Media Storage
How does the data get its vectors in the first place? You cannot just rely on user searches. You need an ingestion pipeline. When a new service is submitted, a background worker picks it up.
I wrote a simple Bun script that runs on a cron job. It pulls pending submissions and calls the Voyage API to update the database.
// worker.ts
import { db } from './db';
import { services } from './schema';
import { isNull, eq } from 'drizzle-orm';
import { getVoyageEmbedding } from './voyage';
async function processPendingEmbeddings() {
const pending = await db.select()
.from(services)
.where(isNull(services.embedding))
.limit(50);
for (const service of pending) {
try {
const textToEmbed = `${service.name}. ${service.description}`;
const vector = await getVoyageEmbedding(textToEmbed);
await db.update(services)
.set({ embedding: vector })
.where(eq(services.id, service.id));
console.log(`Processed: ${service.name}`);
} catch (error) {
console.error(`Failed to process ${service.name}:`, error);
}
}
}
// Run every 5 minutes
setInterval(processPendingEmbeddings, 1000 * 60 * 5);For media, I use Cloudflare R2 to store the hero images and logos. R2 has zero egress fees, which is critical when you are building a highly visual platform. If your site looks like Netflix, you are going to serve a massive amount of image bandwidth. Standard AWS S3 egress would bankrupt a solopreneur within a month.
The Frontend Loop: React 19
A discovery platform lives or dies by its UX. We need horizontal scrolling carousels. React 19 introduces some beautiful unopinionated ways to handle async states, making it easier to load these rows independently without blocking the main thread.
I refactored the old data fetching logic to use the new use hook natively within Suspense boundaries. This cleans up the component tree massively. No more useEffect chains just to fetch a carousel of tools.
// Carousel.tsx
import { Suspense, use } from 'react';
import { SkeletonCard } from './SkeletonCard';
// A promise that fetches our category data
const fetchCategory = async (category: string) => {
const res = await fetch(`/api/services/category/${category}`);
return res.json();
};
function ServiceRow({ category, dataPromise }: { category: string, dataPromise: Promise<any> }) {
// React 19 'use' hook suspends the component until the promise resolves
const services = use(dataPromise);
return (
<div className="flex overflow-x-auto gap-4 py-4 scrollbar-hide">
{services.map(service => (
<div key={service.id} className="min-w-[300px] rounded-lg bg-zinc-900 overflow-hidden hover:scale-105 transition-transform">
<img src={service.heroImageUrl} alt={service.name} className="w-full h-40 object-cover" />
<div className="p-4">
<h3 className="text-white font-bold">{service.name}</h3>
<p className="text-zinc-400 text-sm line-clamp-2">{service.description}</p>
</div>
</div>
))}
</div>
);
}
export function CategoryCarousel({ category }: { category: string }) {
const dataPromise = fetchCategory(category);
return (
<section className="my-8">
<h2 className="text-2xl font-bold text-white px-4">{category}</h2>
<Suspense fallback={<div className="flex gap-4 px-4"><SkeletonCard /><SkeletonCard /><SkeletonCard /></div>}>
<ServiceRow category={category} dataPromise={dataPromise} />
</Suspense>
</section>
);
}This code is declarative. The component says "I need data." If the data isn't there, it suspends, and the nearest Suspense boundary throws up a skeleton loader. It mimics the exact loading pattern you see on modern streaming apps.
Every time a user hovers over a service card, we can prefetch the detailed route. Because I rely on static site generation (SSG) for the individual service pages, the Time to First Byte (TTFB) is virtually zero. SSG also solves the SEO problem. When someone shares a curated collection of "Best GenAI Tools for Startups" on Twitter, the Open Graph tags need to render instantly.
The Real Takeaway
I genuinely don't know how to feel about the sheer volume of software being created right now. Half the time, I am inspired by the ingenuity of solo devs. The other half, I am exhausted by the churn.
Building a visual discovery engine did not slow down the firehose. But it did change my relationship with it. I can browse categorically without feeling anxious about missing out on the daily feed. I can save items to visual collections. It feels like walking through a well-organized library rather than drinking from a fire hydrant.
The tooling to build this kind of AI-powered semantic search used to require a dedicated machine learning team. Now, it takes a weekend alongside Postgres and a Bun runtime. That is the real lesson here. The barrier to building intelligent curation systems has collapsed. Stop hoarding bookmarks in plaintext files. Go build your own library.