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 devOpen 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
# SendGrid transactional email
SENDGRID_API_KEY=SG.xxxxxx
SENDGRID_TEMPLATE_ID=d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[email protected]
SENDGRID_FROM_NAME="Breakpoint Events Team"
# Optional: keep true while developing to avoid sending real mail
SENDGRID_SANDBOX_MODE=trueNever expose AIRTABLE_PAT to the client. All Airtable reads happen server-side.
- Ensure
.env.localincludes the SendGrid variables above and restart the dev server after changes. - Run
pnpm devand submit the email form at/email-link(or any page renderingEmailForm). - Watch the server logs: you should see
POST /api/auth/request-linkfollowed by a202response from SendGrid. In sandbox mode the HTML payload is logged in the SendGrid dashboard without being delivered. - Disable sandbox mode and use a real inbox to confirm the dynamic template renders correctly, the button links to
/s?key=…, and the link expires after the configured window. - If the email fails to send, check the Vercel logs (or Next.js console locally) for the thrown error, verify the template ID, sender identity, and that the API key has Mail Send permission.
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 tooltipNo 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
JWTwithSITE_SECRET
- Signed
- Transport:
- Sent as
keyquery 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?keyexists, 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.speakerIdagainst 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/revalidateand 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.