Building This Portfolio with TanStack Start and Cloudflare Workers
A deep dive into the architecture of a full-stack portfolio site running on the edge — TanStack Start, D1, Drizzle ORM, and anonymous user fingerprinting
I rebuilt my portfolio from scratch because the static-site-generator approach felt hollow. A list of projects and blog posts is fine, but what if the site could actually do things? Store comments, track reactions, and remember who you are.
So I chose an unconventional stack: TanStack Start running on Cloudflare Workers with D1 as the database. It's still evolving — the framework is young, the documentation lags reality, and deployment quirks kept me guessing. But it works, and it's fast. This is what I built and why.
The Stack
TanStack Start (v1.154) is a full-stack React framework that emerged from TanStack Router. Unlike Next.js, it's framework-agnostic about the runtime — you can deploy to Node, Deno, Bun, or Workers. It gives you type-safe routing and server functions via createServerFn. Everything is end-to-end TypeScript.
Cloudflare Workers is serverless compute at the edge. No servers to manage, automatic scaling, deployed globally. The free tier is generous — 100,000 requests/day. D1 is their SQLite solution; data lives in a replicated SQLite database accessible from your Workers. R2 is S3-compatible object storage for assets.
Drizzle ORM pairs perfectly with D1. It's lightweight, TypeScript-first, and generates zero runtime overhead compared to raw queries. The schema definition is clean:
export const comments = sqliteTable('comments', {
id: integer('id').primaryKey({ autoIncrement: true }),
commentableType: text('commentable_type', { enum: ['post', 'project'] }).notNull(),
commentableId: integer('commentable_id').notNull(),
anonymousUserId: integer('anonymous_user_id')
.notNull()
.references(() => anonymousUsers.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
parentId: integer('parent_id'),
depth: integer('depth').notNull().default(0),
status: text('status', { enum: ['pending', 'approved', 'spam', 'deleted'] })
.notNull()
.default('pending'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
})The frontend uses Tailwind CSS 4 with OKLch color spaces, shadcn/ui for components, and Framer Motion for motion. Code highlighting via Shiki with the JavaScript regex engine (no WebAssembly — Workers has restrictions).
Content Without a CMS
The portfolio's content lives in plain text MDX files: content/posts/*.mdx and content/projects/*.mdx. At build time, Vite's import.meta.glob loads these as raw strings — no filesystem access needed at runtime on Workers.
const postModules = import.meta.glob('/content/posts/*.mdx', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>The processing pipeline is: gray-matter extracts frontmatter → remark parses markdown → rehype converts to AST → rehype-pretty-code applies syntax highlighting → rehype-stringify outputs HTML. All of this happens at build time.
The critical gotcha: Cloudflare Workers blocks new Function(), which most MDX compilers rely on. So I pre-compile to static HTML and render it with dangerouslySetInnerHTML. Not ideal for interactive components, but works reliably.
Reading time calculation and table-of-contents extraction happen via a custom rehype plugin that walks the AST:
export async function compileMDX<T extends Record<string, unknown>>(
source: string
): Promise<MDXContent<T>> {
const { data, content } = matter(source)
const readingTimeResult = calculateReadingTime(content)
let tocItems: TOCItem[] = []
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypePrettyCode, rehypePrettyCodeOptions)
.use(rehypeExtractToc, {
callback: (toc: TOCItem[]) => {
tocItems = toc
},
})
.use(rehypeStringify, { allowDangerousHtml: true })
const result = await processor.process(content)
const html = String(result)
return {
frontmatter: data as T,
html,
readingTime: readingTimeResult,
toc: tocItems,
}
}Server Functions — The Backbone
Every backend operation uses createServerFn. It's a TanStack pattern that wires client and server through RPC. Define a function server-side, call it from React components, and the framework handles serialization.
Input validation is lightweight — you pass a validator function that does runtime checking:
export const getComments = createServerFn({ method: 'GET' })
.inputValidator((input: { type: 'post' | 'project', id: number }) => input)
.handler(async (ctx): Promise<CommentWithAuthor[]> => {
const { type, id } = ctx.data
const db = getDB()
// Query comments by commentableType, commentableId, and status='approved'
const allComments = await db
.select()
.from(comments)
.where(
and(
eq(comments.commentableType, type),
eq(comments.commentableId, id),
eq(comments.status, 'approved')
)
)
// Batch fetch display names for all comment authors
const userIds = [...new Set(allComments.map(c => c.anonymousUserId))]
const users = await db
.select({ id: anonymousUsers.id, customUsername: anonymousUsers.customUsername })
.from(anonymousUsers)
.where(inArray(anonymousUsers.id, userIds))
const userMap = new Map(users.map(u => [u.id, u.customUsername]))
return allComments.map(comment => ({
...comment,
displayName: userMap.get(comment.anonymousUserId) || getDisplayName(comment.anonymousUserId),
}))
})The database instance is fetched fresh inside each handler via getDB(). Workers isolates are ephemeral — module-scope state doesn't persist across requests. I learned that the hard way when an in-memory comment cache vanished mid-session. Everything must be persisted to D1.
Anonymous Users Without Sign-Up
No login system. Users are identified by a browser fingerprint.
The fingerprint collects multiple signals: canvas rendering (how different browsers render the same text slightly differently), screen resolution, timezone, language, platform, hardware concurrency, color depth, device memory, and touch support. These are hashed together using the Web Crypto API's SHA-256:
function collectFingerprintData(): FingerprintData {
return {
canvas: getCanvasFingerprint(),
screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: navigator.language,
platform: navigator.platform,
userAgent: navigator.userAgent,
hardwareConcurrency: navigator.hardwareConcurrency || 0,
colorDepth: screen.colorDepth,
deviceMemory: navigator.deviceMemory,
touchSupport: navigator.maxTouchPoints ? navigator.maxTouchPoints > 0 : false,
}
}
async function hashString(str: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(str)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export async function generateFingerprint(): Promise<string> {
const data = collectFingerprintData()
const combined = JSON.stringify(data)
return hashString(combined)
}The same browser always generates the same fingerprint, deterministically. Server-side, there's a get-or-create pattern: first visit creates a record in the anonymousUsers table, subsequent visits increment visitCount.
Users can optionally set a custom display name (3-20 alphanumeric chars plus underscore). The server validates it and checks against a profanity filter. The fingerprint is cached in localStorage for instant recognition on reload.
Comments, Reactions, and Profanity Filtering
Comments are polymorphic — attached to posts or projects via commentableType and commentableId. Replies nest one level deep (max depth of 1).
Reactions are emoji-like: like, love, fire, clap, thinking. One per type per user. A unique constraint in the database prevents duplicates:
unique().on(table.reactableType, table.reactableId, table.anonymousUserId, table.reactionType)The UI updates optimistically — show the reaction immediately, then sync to the server. If it fails, rollback. Most of the time it's instant.
Comment content is filtered for profanity before storage. The filter combines a word list with regex patterns to catch variations, character repetition (e.g., "h3ll0" becomes "hello"), and leetspeak. It's not perfect, but catches most obvious abuse.
Deployment and the Worker Entry Point
TanStack Start bundles to a Worker entry point. I provide a custom entry in src/worker-entry.ts that sets up the environment and handles routing specifics:
export default {
async fetch(request: Request, env: unknown) {
// Set env globally so server functions can access D1, R2, etc.
globalThis.env = env
const url = new URL(request.url)
// Redirect www to apex domain
if (url.hostname.startsWith('www.')) {
const apexUrl = new URL(request.url)
apexUrl.hostname = url.hostname.replace('www.', '')
return Response.redirect(apexUrl.toString(), 301)
}
// Pass to TanStack Start handler
return tanstackHandler.fetch(request, env as any)
},
}The build process is straightforward: vite build bundles everything, wrangler deploy pushes to Cloudflare. Migrations are applied via wrangler d1 migrations apply portfolio-db --remote.
The wrangler.toml binds D1 and R2:
[[d1_databases]]
binding = "DB"
database_name = "portfolio-db"
database_id = "0c3476ec-bed2-4deb-b8f2-e2f3800a8e75"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "portfolio-assets"What Worked Well
Server functions are excellent. Type safety across the network boundary, no REST API to maintain, simple data passing. It's a joy to use compared to fetch/API routes.
Drizzle with D1 is a perfect pairing. The ORM stays out of the way, the schema is readable, and migrations are straightforward. I've had no runtime issues.
Fingerprinting is surprisingly reliable. I was skeptical it would work for real identity, but in practice it sticks. Users come back, their fingerprint matches, their display names persist. No spam has emerged.
Edge deployment makes everything fast. The portfolio responds in under 100ms globally. No cold starts because Workers runs continuously.
What I'd Do Differently
TanStack Start is young. APIs change between minor versions. The documentation is sometimes outdated. I've hit issues where something worked locally with vite dev but broke in the Worker. You need to be comfortable reading source code to debug.
D1 has rough edges. No transactions across tables. SQL features are limited. The dashboard is basic. Backups require manual setup. It works for my scale but wouldn't trust it for high-volume production.
The MDX → HTML pre-compilation is a workaround, not ideal. I can't use interactive components. If I wanted that, I'd need a different content strategy or abandon Workers.
I should've used D1 from day one. My first version tried storing data in-memory, per-isolate. That was a mistake. One data loss incident later, I moved everything to D1 and never looked back.
The Numbers
- D1 Tables: 10 (users, content, interactions, analytics)
- Server Functions: 20+
- Build Time: ~4 seconds
- Bundle Size: ~850KB (server entry)
- Monthly Cost: $0 (free tier covers my usage)
- Deployment Time: ~30 seconds
Why This Setup
I wanted a portfolio that felt alive. Static sites are fast but boring. This one remembers visitors and lets them leave thoughts. It's deployed on the global edge, built with modern tooling, and costs nothing to run.
Is it the right choice for everyone? No. If you want maximum stability, pick a boring stack. If you want to learn edge computing and build something interesting, this works.
The site is live at goamaan.dev. Leave a comment, see what you think.