A Data-Driven Portfolio Landing Page Where Adding an Entry Doesn't Rebuild the App
Goal: ship 100+ open-source projects. Sub-problem: what renders the list? If the landing page is hardcoded, every new entry means a full frontend rebuild. The fix turned out to be the oldest one in the book — put the data in a JSON file and fetch it at runtime. Here's the React implementation, and the architectural detail that made the whole series work.
I set myself the goal of publishing 100+ public OSS projects. As the count climbed, one architectural question kept blocking me: where does the landing page live? Writing new entries into the company homepage means redeploying the corporate site every time. Bundling the list into a static React app means a frontend rebuild per entry. Both add friction that compounds as the count grows.
The right answer turned out to be: separate data from display, and have the display fetch the data at runtime. The data lives in portfolio/data/entries.json (the private repo's source of truth). The display is this app — a React + TypeScript + Vite SPA served at sen.ltd/portfolio/, which does fetch('/portfolio/data.json') on load.
🔗 Live demo: https://sen.ltd/portfolio/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-react
Filter by category / stack / stage, search by name or pitch or tag, sort by number / newest / oldest / name, URL query sync for shareable filter links, bilingual JA/EN. React 18 + TS + Vite, gzip 49.00 kB.
The runtime-fetch decision
A typical React app would import data from '../data/entries.json', baking the content into the bundle at build time. This app does not:
const res = await fetch('/portfolio/data.json', { cache: 'no-store' })
const data = await res.json()
The difference is most visible in the deploy flow:
portfolio/data/entries.json ← add one line
↓
./deploy-site.sh _data prod ← uploads JSON to S3, finishes in ~2 seconds
↓
next user visit sees the new entry (after CloudFront cache TTL)
The React bundle itself is never rebuilt for new entries. It's built once, deployed once, and serves every future state of the portfolio. The entire "add an entry" operation is a one-line JSON edit followed by an S3 upload.
This also means the frontend code is stable enough to fork for the framework comparison series (more on that below) — I don't have to worry about a rewrite breaking content updates.
Type definitions mirror the JSON schema
The data schema is defined in portfolio/data/schema.json and mirrored in TypeScript:
export type Entry = {
slug: string
number: number
name: LocalizedText
pitch: LocalizedText
stage: StageId
source?: Source // 'public' | 'closed' — for SaaS entries
category: string
tech: string[]
tags?: string[]
github: string | null
demo: string | null
image: string | null
articles: Article[]
testCount?: number
createdAt: string
}
export type PortfolioData = {
version: string
updatedAt: string
entries: Entry[]
categories: Category[]
stacks: Stack[]
stages: Stage[]
}
Because the data arrives at runtime, the type safety has to be enforced at runtime too. TypeScript's asserts syntax is great for this:
function assertPortfolioData(raw: unknown): asserts raw is PortfolioData {
if (typeof raw !== 'object' || raw === null) throw new Error('not an object')
const obj = raw as Record<string, unknown>
if (!Array.isArray(obj.entries)) throw new Error('entries missing')
if (!Array.isArray(obj.categories)) throw new Error('categories missing')
// ...
}
Silencing the checker with as PortfolioData would let a broken data.json crash the UI in confusing ways. The asserts helper checks the shape and narrows the type at the same time, which is the right tool for the "data from the wire" gap.
Filter / search / sort as pure functions
All filtering logic lives in src/filter.ts as pure functions:
export function filterAndSort(
entries: Entry[],
filter: FilterState,
lang: Lang
): Entry[] {
const q = filter.query.trim().toLowerCase()
const filtered = entries.filter((e) => {
if (filter.category !== 'all' && e.category !== filter.category) return false
if (filter.stack !== 'all' && !e.tech.includes(filter.stack)) return false
if (filter.stage !== 'all' && e.stage !== filter.stage) return false
if (q) {
const hay = (e.name[lang] + '' + e.pitch[lang] + '' + (e.tags ?? []).join('')).toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
return sortEntries(filtered, filter.sort)
}
Separating this from React means it's testable with Vitest, independently of DOM, in millisecond runtimes:
import { filterAndSort } from './filter'
// 14 pure-function tests, no React in the loop
Keep business logic in pure functions, keep the component layer rendering-only. For a browser-side SPA this is small UI discipline but pays dividends when you factor across multiple framework implementations (see below).
URL query sync
The filter state lives in the URL so filtered views are shareable:
function writeQuery(filter: FilterState, lang: Lang) {
const q = new URLSearchParams()
if (filter.query) q.set('q', filter.query)
if (filter.category !== 'all') q.set('category', filter.category)
if (filter.stack !== 'all') q.set('stack', filter.stack)
q.set('lang', lang)
window.history.replaceState(null, '', `${window.location.pathname}?${q.toString()}`)
}
replaceState (not pushState) keeps the browser history clean — every keystroke in the search input doesn't create a new history entry. The inverse operation reads the query on startup and initializes the filter state from it.
Closed-source entries
The data model supports an optional source: 'closed' field for SaaS entries that don't have public repos. The component shows a 🔒 badge and the header meta line splits counts:
{(() => {
const saas = data.entries.filter((e) => e.source === 'closed').length
const oss = data.entries.length - saas
return m.totalCount(oss, saas) // "30 OSS · 1 SaaS"
})()}
The 100-OSS goal stays clean, and existing SaaS products can still be showcased side-by-side. Adding this was a two-line data model change and a small render change — exactly the kind of evolution a data-driven landing page should support without disruption.
Anchor of the framework comparison series
This React app is also the first in a framework comparison series where the same spec, same data, and same CSS are reimplemented across every major JS framework and the byte count is measured:
- 021 React — gzip 49.00 kB
- 022 Vue 3 — 28.76 kB (−41%)
- 023 Svelte 5 — 18.92 kB (−61%)
- 024 Solid — 8.33 kB (−83%)
- 025 Nuxt 3 — 52.01 kB (+7%) ← only one heavier than React
- 026 SvelteKit — 32.50 kB (−33%)
- 027 Qwik — first-paint 28.60 kB (−41%)
- 028 Astro islands — 3.17 kB (−94%, series record)
- 029 Lit 3 — 9.70 kB (−80%)
- 030 Preact — 8.75 kB (−82%)
The shared code (types.ts, filter.ts, data.ts, style.css, tests) is byte-identical across ports. Only the component layer differs. That constraint lets the byte measurements actually mean something — you're comparing framework overhead, not implementation style.
Each port has its own writeup in this series. If you're curious how any specific framework's rendering layer reads side-by-side, subscribe and follow the series.
Tests
14 Vitest cases, all on filter.ts:
test('filters by category', () => {
const r = filterAndSort(entries, { ...defaults, category: 'dev-tool' }, 'en')
assert.ok(r.every((e) => e.category === 'dev-tool'))
})
test('searches by pitch', () => {
const r = filterAndSort(entries, { ...defaults, query: 'cron' }, 'en')
assert.ok(r.some((e) => e.slug === 'cron-tz-viewer'))
})
test('sorts by newest', () => {
const r = filterAndSort(entries, { ...defaults, sort: 'newest' }, 'en')
for (let i = 0; i + 1 < r.length; i++) {
assert.ok(r[i].createdAt >= r[i + 1].createdAt)
}
})
Zero React in the test setup. Tests run in under 200ms and stay fast as the portfolio grows.
Series
This is entry #21 in my 100+ public portfolio series. It's the landing page itself, so it's also the foundation for every subsequent browsing and comparison entry.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-react
- 🌐 Live: https://sen.ltd/portfolio/
- 🏢 Company: https://sen.ltd/
Next entries (022 onward) port this exact same spec to Vue, Svelte, Solid, Nuxt, SvelteKit, Qwik, Astro, Lit, and Preact, and compare bundle sizes. If framework bundle-size comparisons are your jam, follow the series.