Building ByteNews: A High-Density Hacker News Clone with Next.js App Router, Vercel Geist, and Cloudflare D1

In an era dominated by sprawling dashboard bloat, massive border radiuses, and oversized cards, classic Hacker News remains the gold standard of developer information density.

I set out to build ByteNews — a modern, production-grade full-stack reimagining of Hacker News that combines the high-density aesthetic of Vercel's Geist design system with modern web architecture: Next.js App Router, Server Actions, Prisma ORM, and Cloudflare D1 running directly inside Cloudflare Pages Functions.

Here is an architectural deep dive into how ByteNews works and how you can run it or build something similar.

1. Design & Typography: The Vercel Geist Aesthetic

Hacker News works because it gets out of your way. ByteNews embraces that philosophy with the high-contrast monochrome elegance of Vercel Geist:

  • Palette: High-contrast monochrome with class-based dark/light mode (#000000 OLED dark mode, #0A0A0A elevated surfaces, #EDEDED text; and #FFFFFF / #FAFAFA in light mode).
  • Hairline Dividers: 1px borders (border-neutral-200 dark:border-neutral-800) without drop shadow bloat or unnecessary rounded card wrappers.
  • Strict Accent Rule: The classic Hacker News orange (#FF6600) is reserved strictly for interactive highlights: the brand badge [B], active navigation tabs, upvote triangles, and primary submit actions.
  • Dual Typography:
    • GeistSans for post titles, article commentary, and discussion bodies.
    • GeistMono (with tabular-nums) for ranks, points, comment tallies, timestamps, karma counts, and domain pills.

2. Zero-Latency Upvoting with React useOptimistic & Server Actions

Traditional web apps often require spinners or full page refreshes to record a vote. In ByteNews, upvoting a story or comment feels instantaneous (0ms perceived latency) using React 18/19's useOptimistic hook paired with Next.js Server Actions:

export function PostItem({ post, currentUserId }: PostItemProps) { const [isPending, startTransition] = useTransition(); const [optimisticState, setOptimisticState] = useOptimistic( { points: post.points, hasVoted: Boolean(post.hasVoted), }, (state) => { const willBeVoted = !state.hasVoted; return { hasVoted: willBeVoted, points: willBeVoted ? state.points + 1 : Math.max(0, state.points - 1), }; } ); const handleVote = async () => { if (!currentUserId) return; startTransition(async () => { setOptimisticState({}); await votePostAction(post.id); }); }; return ( <div className="flex items-start gap-2.5 py-2"> <button onClick={handleVote} className={optimisticState.hasVoted ? "text-[#FF6600]" : "text-neutral-400 hover:text-[#FF6600]"} > ▲ </button> <div className="font-mono text-xs"> {optimisticState.points} points </div> </div> ); } 

When a user taps upvote:

  1. The UI increments the counter and highlights orange immediately.
  2. The Server Action votePostAction executes in the background, writes to the database in a transaction, updates author karma, and revalidates feed caches.
  3. If anything fails (or if an unauthorized user attempts to vote), the optimistic state seamlessly rolls back.

3. The Hacker News Gravity Ranking Formula

The core feed isn't just sorted by raw points or chronology; it balances freshness and community interest using the classic Hacker News gravity decay formula:

$$\text{Score} = \frac{\text{Points} - 1}{(\text{AgeInHours} + 2)^{1.8}}$$

Implemented in TypeScript:

export function calculateHnScore(points: number, createdAt: Date | string | number): number { const itemDate = new Date(createdAt).getTime(); const now = Date.now(); const ageInHours = Math.max(0, (now - itemDate) / (1000 * 60 * 60)); const gravity = 1.8; const adjustedPoints = Math.max(0.1, points - 0.8); return adjustedPoints / Math.pow(ageInHours + 2, gravity); } 

Stories with hundreds of points gradually sink over 24-48 hours, allowing high-velocity new submissions to surface organically.

4. Cloudflare D1 + Prisma ORM in Pages Functions (No Extra Workers)

One of the common challenges with deploying full-stack Next.js apps to edge platforms like Cloudflare Pages is database connectivity. Standard Prisma uses a native Node.js binary query engine that cannot run on edge V8 runtimes.

ByteNews solves this cleanly using Prisma Driver Adapters with Cloudflare D1 (@prisma/adapter-d1):

// prisma/schema.prisma generator client { provider = "prisma-client-js" previewFeatures = ["driverAdapters"] } datasource db { provider = "sqlite" url = env("DATABASE_URL") } 

Inside our Prisma client singleton (src/lib/prisma.ts), we dynamically detect whether we're running inside a Cloudflare Pages Function with a native DB binding or local development:

import { PrismaClient } from "@prisma/client"; import { PrismaD1 } from "@prisma/adapter-d1"; export function getPrisma(): PrismaClient { const g = globalThis as any; const d1Binding = g.DB || (typeof process !== "undefined" && (process.env as any)?.DB); if (d1Binding && typeof d1Binding.prepare === "function") { // Native Cloudflare D1 on Pages Functions const adapter = new PrismaD1(d1Binding); return new PrismaClient({ adapter }); } // Fallback to local SQLite in dev if (!global.prisma) { global.prisma = new PrismaClient(); } return global.prisma; } 

This delivers the best of both worlds:

  • Zero-config local development: Runs instantly with standard SQLite (file:./dev.db).
  • Production Edge Scale: Runs on Cloudflare Pages Functions directly querying Cloudflare D1 with global distribution and zero auxiliary worker microservices.

5. Offline & Poor Network Resilience

ByteNews ships with a lightweight custom Service Worker (public/sw.js):

  • Cache-First: For immutable static bundles (/_next/static/), SVG icons, and typography fonts.
  • Stale-While-Revalidate: For visited discussion threads and feed routes.
  • Offline Pill: When network connection drops, an animated Cached / Offline status pill appears in the top navigation bar, letting developers read cached stories seamlessly.

Try It & Contribute

The entire codebase is open-source, modular, and ready for you to explore:

If you like what you see, give the repository a star on GitHub and feel free to contribute or fork it for your own community!