A focused Airtable-driven microsite for Breakpoint speakers. Private-but-shareable pages (no login required) via JWT-signed links, fresh data from Airtable (server-side only), and convenient ICS calendar feeds.
Speaker links are availble in airtable, but can also be found in the server logs where src/app/api/auth/request-link/route.ts
will log them as they are requested in the email form.
- Next.js (App Router, TypeScript) — deployed on Vercel
- Tailwind CSS + shadcn/ui (Radix) — accessible UI primitives
- Airtable REST API — primary data source
- Zod — runtime validation of Airtable responses
- TanStack Table (+
@tanstack/react-virtual
) — fast agenda tables - Luxon — timezone-safe formatting
- ICS — generate calendar files server-side
- JWT — signed access tokens (no login)
- Optional: Sentry (observability), Upstash Ratelimit (API protection)
This is a Next.js project bootstrapped with create-next-app
.
pnpm install
pnpm dev
# or: npm run dev / yarn dev / bun dev
Open http://localhost:3000.
Create .env.local
:
AIRTABLE_PAT=pat_XXXXXXXXXXXX
AIRTABLE_BASE=app_XXXXXXXXXXXX
AIRTABLE_TABLE_SPEAKERS=Speakers
AIRTABLE_TABLE_SESSIONS=Sessions
SITE_SECRET=change_me_hmac_key
VENUE_TZ=Asia/Dubai
# Auth token issuance for /api/auth/request
API_KEY=server_only_random_value
# Milliseconds a token is valid from "now" when issued -eg. 3M
NEXT_PUBLIC_KEY_EXP=7776000000
Never expose AIRTABLE_PAT
to the client. All Airtable reads happen server-side.
shadcn is initialized. Add the primitives you need as you implement pages:
npx shadcn add button input badge card table dialog sheet tabs dropdown-menu toast separator alert avatar tooltip
No login. Access via signed links: /s/[slug]?key=<jwt>
Airtable views as source of truth (e.g., Onboarded Speakers > For Web, Agenda > For Web)
/s/[slug]
— speaker portal (profile, sessions, ticket, shareables, highlights)/schedule
— agenda with Day (1–3), Stage (A/B), Type filters
/api/auth/request
— mint a short-lived JWT for access (server-to-server)/api/speakers/[slug]
— server data for speaker page/api/sessions
— list/filter sessions for agenda/api/ics/session/[id]
,/api/ics/speaker/[slug]
,/api/ics/event
— calendar feeds
Robots: Disallow /s/*
and /api/ics/*
(private-ish but shareable)
app/
s/[slug]/page.tsx
schedule/page.tsx
api/
auth/request/route.ts
speakers/[slug]/route.ts
sessions/route.ts
ics/
session/[id]/route.ts
speaker/[slug]/route.ts
event/route.ts
revalidate/route.ts
robots.ts
globals.css
lib/
airtable/
client.ts # fetch wrapper (server-only), short ISR
zod.ts # Speaker/Session schemas
speakers.ts # getSpeakerBySlug(...)
sessions.ts # listSessions(...filters)
sign.server.ts # JWT sign/verify helpers
time/
tz.ts # Luxon helpers (venue tz, user tz)
ics/
build.ts # util to generate ICS content
components/
speaker-card.tsx
schedule-table.tsx
highlights-gallery.tsx
middleware.ts # optional: extra headers for /s/*
JWT payload and usage:
- Claims:
slug
— audience marker for the resource (e.g.,auth
,schedule
,ics
)speakerId
— optional, used when scoping access to a specific speakerexp
— expiration (unix seconds)
- Signing:
- Signed
JWT
withSITE_SECRET
- Signed
- Transport:
- Sent as
key
query param, e.g.,/s?key=<jwt>
- Sent as
Helpers in lib/sign.server.ts
:
generateKey(expMs, slug, speakerId?)
— returns a JWT stringisAuthenticated(request, slug?)
— checks?key
exists, verifies signature andslug
(defaults to"auth"
)getTokenPayload(key)
— returns decoded claims ornull
In dev mode, the server logs a valid token for the checked slug when verification runs.
Key slugs:
/schedule
—schedule
/s/*
—auth
(and validatepayload.speakerId
against the route param if applicable)/api/ics*
—ics
POST /api/auth/request
- Auth: require header
x-api-key: <API_KEY>
- Body:
{ "speakerId": "rec123456789" }
- Behavior:
- Computes
exp = Date.now() + Number(NEXT_PUBLIC_KEY_EXP)
- Signs a JWT with claims
{ slug: "auth", speakerId, exp }
- Computes
- Response:
{ "token": "<jwt>", "speakerId": "spk_123", "slug": "auth", "exp": 1731234567 }
Use the returned token
as ?key=<token>
on protected URLs.
Query filtered views (e.g., For Web) so only confirmed, public-safe fields flow to the UI.
Prefer denormalized fields for speed (e.g., SessionsExpanded JSON via Automation), otherwise join with a second request and cache.
Use fetch(..., { next: { revalidate: 60, tags: ["agenda","speaker:slug"] } })
Have Airtable Automation → POST to /api/revalidate
on record changes; revalidate tag(s) for the touched speaker/day.
Default to ICS subscribe buttons (silent updates).
Keep Luma “Add to calendar” secondary if needed.
Add noindex meta on /s/[slug]
.
robots.ts
disallows /s/
and /api/ics/
.
Rate-limit /api/*
(especially ICS) if scraped.
Never pass Airtable secrets to the browser.
Validate all external data with Zod before render.
- Guard
/s/[slug]
and relevant APIs with JWT verification - Implement Airtable fetchers + Zod schemas
- Build SpeakerCard, TicketCard, ScheduleTable (TanStack Table + filters)
- Add ICS routes (session, speaker, event)
- Wire
/api/revalidate
and Airtable Automation - Add noindex + robots rules
- Add basic rate limiting and Sentry (optional)
- Smoke test: 1 speaker, 2 sessions, ICS import in Apple/Google/Outlook
pnpm dev # run local server
pnpm build # production build
pnpm start # run production
pnpm lint # lint
- Create project speakers-solana-com
- Set all Environment Variables
- Assign custom domain speakers.solana.com
- (Optional) Add Sentry via wizard
Small, focused PRs are ideal. Add notes inline if you divert from this README—this doc is a guide, not a gate.