A lab site nobody could update
The lab’s old WordPress site had drifted out of date because updating it needed a developer. Publications, members, news and openings change every month; the person who knows them is the professor.
Notion as the CMS, code as the fallback
Every content type — members, publications, news, gallery, projects, openings, FAQs — is a Notion table the professor edits directly. The site reads them through one typed module of getters; pages never call Notion themselves. Hourly revalidation keeps it fresh, and a token-protected refresh endpoint makes a change instant.
If Notion is empty or down, the site still renders from built-in data. Korean and English are rendered together and switched with a single attribute, so a missing translation is visible rather than silent.
Context
The TUPA lab’s website had stopped reflecting the lab. Members, publications and news change monthly, and the only person who knows them is the professor, who should not need a developer to update a list.
I started the rebuild two weeks before my first day in the lab and shipped it over the summer. The design decision that mattered most was not visual: content lives in Notion tables the professor already uses, and the site survives Notion being empty or down.
The professor edits a row in Notion
A new paper goes into the Publications table. No code, no deploy, no developer.
11 Notion databases: members, publications, news, gallery, projects, research areas, journeys, openings, FAQs, settings, page copy
Getters fetch on a schedule
Every page reads through one typed module of nineteen getters. Each fetch is cached for an hour and tagged, so the whole site revalidates together.
lib/content.ts · 4,017 lines · 22 types · revalidate 3600 · tag notion · raw Notion REST, no SDK
Pages render in both languages
Korean and English are rendered into the same HTML and switched with one attribute, so a missing translation is visible rather than silent.
html[data-lang] · .ko-only / .en-only · 13 pages, 4 dynamic · no [locale] segment
Instant refresh when it matters
For a time-sensitive change, a token-protected endpoint revalidates the tag and the layout immediately.
GET /api/refresh?token=… → revalidateTag + revalidatePath(/, layout)
Notion is down. The site is not.
Every getter is Notion-first with a built-in fallback: the archive migrated from the old WordPress export. If Notion is empty or unreachable, the pages still render.
fallback data: ~52 members · ~153 publications · 47 news · 17 projects · 5 openings
Live on Vercel
Static where possible, revalidated hourly, with analytics and a photo proxy for Notion’s expiring image links.
inhi.kim · 75 commits, 2 July – 1 September 2026 · /api/photo proxies Notion S3 URLs
- prof
- Prof. Inhi Kim’s lab, KAIST Cho Chun Shik Graduate School of Mobility · edits Notion directly
- notion
- 11 databases · Notion-Version 2022-06-28 · workspace migrated to the professor’s account on 21 July 2026
- getters
- 19 async getters, each Notion-first with a static fallback · pages never call Notion themselves
- pages
- research · publications · people · news · gallery · projects · prospective students · contact + 4 dynamic routes
- vercel
- Hourly ISR · @vercel/analytics · GA4 added later by the lab
- isr
- fetch revalidate 3600 with tag notion · the whole site refreshes together
- refresh
- Token-protected · revalidateTag + revalidatePath · used after urgent edits
- fallback
- Curated TS constants and JSON seeded from the WordPress WXR export
Shipped in September 2026
Thirteen pages, four of them dynamic, a hero video upscaled to 4K, migration scripts from the old WordPress archive, and a short user guide for the lab. The design system, Daylight Atlas, is the lab’s own.
One content surface
The rule that kept the codebase sane: pages only read from lib/content.ts. Twenty-two types, nineteen getters, each one Notion-first with a static fallback. Notion is called over raw REST rather than the SDK, with database ids overridable by environment, which made the move to the professor’s workspace in July a config change.
From WordPress to tables
The old site was exported from WordPress and became curated TypeScript constants and JSON, then seed payloads for the Notion databases: fifty-one members, seventy-three papers plus later journal additions, over a hundred news items, forty-four gallery albums, seventeen projects. The same data doubles as the fallback, so the site never ships empty.
Daylight Atlas
The lab’s design system: paper white, cobalt and a safety orange accent, Overpass with IBM Plex Mono labels and Pretendard for Korean. The hero is a nine-second looping film of the TUPA letters as city blocks, generated, animated, upscaled to 4K and encoded to 1920p at under two megabytes.
Parity as a test, not a convention
Bilingual parity in TUPA is structural, both languages render from the same records, but nothing fails when a Korean field is left blank. The next version gets the same kind of test this site has: a build that refuses an untranslated string.
- Stack
- Next.js 14 App Router · TypeScript · Tailwind · framer-motion · Notion REST API · Vercel
- Content
- 11 Notion databases · 19 getters · 22 types · static fallback for every one
- Pages
- 9 static + 4 dynamic routes · 2 API routes (refresh, photo proxy)
- Freshness
- ISR every 60 minutes · instant refresh endpoint
- Languages
- Korean and English rendered together, toggled client-side
- Timeline
- 2 July – 1 September 2026 · 75 commits
- Role
- Designed and built as lab intern · primary developer
- Live
- inhi.kim

