A mobile-first single-screen pre-sale site for a 3000-copy mini-book co-publishing event in Wuhan. Built with Next.js 16 (App Router + Cache Components), shadcn/ui, Tailwind v4, and Notion as the database.
The publisher takes payment offline (WeChat QR), then hands buyers a serial number + a whimsical two-word magic word. Buyers come to the site, sign in, complete their profile, and decide whether they want a printed copy mailed from Wuhan.
- 🎴 Single-screen hero with hanging-mini-book SVG, progress meter (
0247 / 3000), and segmented tabs - ✨ Passwordless sign-in via serial + magic word (e.g.
0247+velvet-otter) - 🪪
/adminpanel to walk every row through its lifecycle (Wished → Issued → Shipped → Delivered) — bigger edits (addresses, tracking numbers, typos) still happen directly in Notion - 🌏 Bilingual: Simplified Chinese (default) + English, switchable via a top-bar toggle
- 🧱 Notion-backed persistence with Next.js 16
'use cache'+cacheTagandrevalidateTag('max')for invalidation - 🎨 Custom luxury-press design system (paper, ink, seal, gold; Cormorant Garamond + Inter)
npm install
cp .env.example .env.local # fill in the values, see "Environment variables" below
npm run devVisit http://localhost:3000 — and http://localhost:3000/admin to manage serials.
| Command | Purpose |
|---|---|
npm run dev |
Start the Turbopack dev server |
npm run build |
Production build (Partial Prerender) |
npm run start |
Run the production build |
npm run lint |
ESLint |
All variables live in .env.local (gitignored). See .env.example for the template.
| Variable | Required | What it is |
|---|---|---|
NOTION_TOKEN |
✅ | Internal-integration secret from https://www.notion.so/profile/integrations. Format ntn_... (or legacy secret_...). |
NOTION_DATABASE_ID |
✅ | The 32-char ID of the Doushu Serials database. Used for documentation; reads/writes go through the data source. |
NOTION_DATA_SOURCE_ID |
✅ | The data-source UUID for the same database (Notion 2025-09-03 API). The admin panel and all queries use this. |
NOTION_PARENT_PAGE_ID |
✅ | The 32-char ID of the parent page that holds the database. Lets the admin "Open in Notion" link work. |
NOTION_PARENT_PAGE_URL |
✅ | Full URL of the parent Notion page (https://www.notion.so/<id> or your custom slug). Linked from /admin. |
NOTION_POEM_AUTHORS_DATA_SOURCE_ID |
— | Data-source UUID for the Doushu Poem Authors database. Powers the "诗歌作者" credit line on the About sheet. Optional — when unset, the site falls back to the static name list baked into content/copy.*.ts. |
NOTION_SETTINGS_DATA_SOURCE_ID |
— | Data-source UUID for the Doushu Settings database. Optional — powers the admin-editable waitlist QR code (QR Code file property on the site row). Falls back to the bundled public/waitlist-qr.jpg when unset. The database's old Ready To Ship checkbox is unused. |
NOTION_WAITLIST_DATA_SOURCE_ID |
— | Deprecated. Wishlist signups now write into Doushu Serials as Wished rows, so a separate waitlist database is no longer needed. The variable is no longer read; you can remove it from .env.local. |
ADMIN_PASSWORD |
✅ | Password gating /admin. Pick something long. Hashed in transit, compared with timingSafeEqual. |
SESSION_SECRET |
✅ | 32+ random bytes used to HMAC-sign buyer + admin cookies (JOSE HS256). Generate with openssl rand -base64 32. |
ℹ️ When the Notion variables are missing, the homepage still renders with
0 / 3000and the wall shows an empty-state. This is intentional so previews don't crash before configuration.
You only do this once. Walk-through:
- Go to https://www.notion.so/profile/integrations → New integration.
- Name it
Doushu. Workspace = the workspace that should own the data. - Capabilities: Read content, Update content, Insert content.
- Copy the Internal Integration Token → this is your
NOTION_TOKEN.
In Notion, create a top-level page called Doushu · 豆书 (or whatever you like). Copy its URL — you'll get the page ID from the URL.
- A URL like
https://www.notion.so/doushu-3499b256b6b081b78ba6f3135ab7862b→NOTION_PARENT_PAGE_ID = 3499b256b6b081b78ba6f3135ab7862bNOTION_PARENT_PAGE_URL = https://www.notion.so/3499b256b6b081b78ba6f3135ab7862b
Open the page → ··· (top-right) → Connections → search Doushu → Confirm. Without this step the API will 404.
Inside the parent page, add an inline database called Doushu Serials with exactly these properties (names matter — the code keys off them):
| Property | Type | Notes |
|---|---|---|
Serial |
Title | Padded string, e.g. 0247 |
Number |
Number | The integer serial (used for ordering and lookups) |
Magic Word |
Text | Two-word phrase like velvet-otter |
Nickname |
Text | Set by the buyer |
Contact |
Text | WeChat / phone / email |
Address |
Text | Optional, for printed-book exchange |
Wants Printed Book |
Checkbox | |
Show on Wall |
Checkbox | Buyer toggles this in MyBookSheet |
Status |
Select | Options: Wished, Issued, Profile Complete, Exchange Requested, Shipped, Delivered, Cancelled. Every row enters as Wished via the 我想要 QR drawer; the publisher advances it through the pipeline from /admin (or directly in Notion). Postage is collected on delivery; the buyer self-confirms receipt to move Shipped → Delivered. |
Issued At |
Created time | |
Updated At |
Last edited time |
Open the database as a full page. URL looks like https://www.notion.so/<workspace>/<DB_ID>?v=<view>. The 32-char hex chunk before the ? is NOTION_DATABASE_ID.
To get NOTION_DATA_SOURCE_ID (Notion's 2025-09-03 API split databases into one or more "data sources"), call:
curl https://api.notion.com/v1/databases/$NOTION_DATABASE_ID \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: 2025-09-03" | jq '.data_sources[0].id'Or, easier: in the Notion MCP plugin, the database response includes data_sources[].id. Paste that UUID in.
The About sheet's "诗歌作者" (poem authors) credit line is admin-editable from Notion instead of hard-coded, since the roster grows over time. Inside the parent page, add an inline database called Doushu Poem Authors with:
| Property | Type | Notes |
|---|---|---|
Name (ZH) |
Title | Name shown on the Chinese site |
Name (EN) |
Text | Name shown on the English site. Leave blank to reuse Name (ZH). |
Order |
Number | Display order, ascending. Leave gaps (10, 20, 30…) so you can insert a new poet without renumbering everything. |
Get its data-source ID the same way as step 5 and paste it into NOTION_POEM_AUTHORS_DATA_SOURCE_ID. To add, remove, rename, or reorder a poet, just edit the rows — no redeploy needed (reads are cached for a few minutes via 'use cache').
Doushu Settings is a single-row "site settings" database (the row's Key is site) that used to power an old admin "ready to ship" toggle — the Ready To Ship checkbox is no longer read; the homepage now always offers both 「我想要」 and 「我有一本」. It now also holds the waitlist QR code shown in the 我想要 drawer:
- If you don't already have it, create an inline database called Doushu Settings inside the parent page with a
Keytitle property, and one row titledsite. - Add a
QR Codeproperty of type Files & media to that database. - On the
siterow, upload (or drag-drop) the QR code image into that property. - Get its data-source ID the same way as step 5 and paste it into
NOTION_SETTINGS_DATA_SOURCE_ID.
The site serves this through a small proxy route (/waitlist-qr) rather than linking Notion's file URL directly, since Notion's hosted file links expire about an hour after being read. When this isn't configured, or the property is empty, or the fetch fails, the route falls back to the bundled public/waitlist-qr.jpg. To rotate the QR code, just replace the file on the site row — no redeploy needed (reads are cached for a few minutes).
Where did
Doushu Waitlistgo? It used to be a separate database for the我想要drawer, but waitlist signups now write intoDoushu Serialsdirectly asWishedrows. Nickname uniqueness, the wall, and the lifecycle all flow through the single serials DB. If you have an oldDoushu Waitlistdatabase lying around, you can archive it — the code no longer reads from it.
openssl rand -base64 32 # → SESSION_SECRETChoose a long random string for ADMIN_PASSWORD.
The homepage will show real counts; /admin will list every row and let you walk it through the lifecycle.
Every row enters Doushu Serials the same way: someone taps 我想要 on the homepage and reserves a wish.
- Wish. Visitor opens the site, taps
我想要, types a nickname, taps保存二维码.- The action auto-issues the next serial, generates a magic word, and writes a row with
Status = Wished,Nickname = <them>,Show on Wall = true. - Their chip appears on
心愿墙immediately as a dashed/ghost book showing the reserved serial. - The QR code downloads and the toast confirms
已加入心愿墙 · 编号 0042 · 二维码已开始下载.
- The action auto-issues the next serial, generates a magic word, and writes a row with
- Hand-off. When you deliver a physical mini-book to that person:
- Open
/admin, find their row in the paginated list, read off the serial + magic word, write them on the inside cover. - Flip the row's status from
Wished→Issued. The wall chip flips from ghost to solid.
- Open
- Sign-in. They sign in with that pair, fill in contact info —
Statusauto-moves toProfile Complete. - Optional printed-book request. They open
My book, request the printed edition, type an address —Statusauto-moves toExchange Requested. - Ship. When the printed book leaves your hands, advance the row to
Shippedin/admin(one tap on the dropdown). - Receipt. The buyer taps Confirm receipt in
My bookand the row becomesDelivered— their wall chip gains a green ✓ ("wish fulfilled"). You can also force this from/adminif a buyer never confirms.
Larger fixes (typos in addresses, manual tracking numbers, archiving spam rows) still happen directly in Notion. The site picks up Notion-side edits within ~60s thanks to cacheLife({ revalidate: 60 }), or instantly if a server action triggers a revalidation.
Wall states recap:
Wished→ outlined dashed book chip with serial in muted ink. Reads as "reserved, not delivered yet".Issued/Profile Complete/Exchange Requested/Shipped→ solid colourful book chip with serial. Book is in the pipeline.Delivered→ solid book chip with a green ✓ in the corner. Wish fulfilled.
Note on the 3000 cap. It's a printing target, not a hard cap. Wishes just call
highestSerial() + 1, so the next serial keeps incrementing — you can have more than 3000 wishes on the wall, and the meter visually tops out at the goal.
app/—page.tsx(single-screen hero),admin/page.tsx(manage serials),actions.ts(all server actions, Zod-validated).components/hero/—HeroShell(client),BookStack(animated SVG),ProgressMeter,SegmentedTabs,TopBar.components/sheets/—SignInSheet,MyBookSheet,WaitlistSheet,InfoPanels,StatusTimeline. Bottom sheets keep the experience single-screen on mobile.components/wall/— server-renderedBuyerWall+BookChip(wished / active / fulfilled variants).components/admin/—AdminLogin,ManagePanel(paginated 5-per-page status manager + reset danger zone).components/system/—LangShell+LangSyncso the cookie-based language read can sit inside a<Suspense>boundary required bycacheComponents.lib/notion/—client.ts(singleton),properties.ts(page ↔ row adapter),repo.ts(cached reads via'use cache'+ writes thatrevalidateTag('max', ...)),tags.ts(shared cache-tag constants),status.ts.lib/auth/session.ts— JOSE-signed buyer + admin cookies.lib/i18n.tsx+lib/server-i18n.ts+content/copy.{zh,en}.ts— i18n.lib/words.ts— magic word generator (adj-noun, ~100×100 combinations, normalized for comparison).
Public reads (listWall, counts) use the Next.js 16 'use cache' directive with cacheTag and cacheLife({ stale: 30, revalidate: 60, expire: 3600 }). Writes call revalidateTag('max', WALL_TAG | COUNT_TAG) to invalidate. This means: zero Notion reads on the hot path, eventual consistency under one minute.
The site is platform-agnostic. Server actions and 'use cache' are standard Next.js features, not Vercel-only.
- Vercel: zero-config. Add the env vars under Project Settings → Environment Variables.
- Netlify / Cloudflare Pages: use their official Next.js adapter. Both support server actions on Next.js 16.
- Self-hosted (Node):
npm run build && npm run start. Make sure the env vars are present at runtime, not just at build time. - Docker: build with
--target runner(standard Next.js Dockerfile recipe).
The site should run as a single instance — there's no in-memory state to share, but the cache is per-instance, so multi-instance setups will see each instance independently revalidate.
Private. © Doushu Press.