Overview#
Summit is a bold, dark website for a conference or event, shown as Summit 2027, a fictional three-day design, engineering and AI conference at The Foundry Hall in Copenhagen (14–16 April 2027). It has a live countdown, a speaker directory with a track filter and pop-up profiles, speaker pages, a day-by-day schedule with a track filter, starred sessions saved in the browser and "On now" / "Up next" highlights during the event, a ticket page with live totals, early-bird pricing and a team discount, and venue, travel and sponsor pages. This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site. An HTML + Tailwind version of the template is also available.
Live demo: freeframe.dev/templates/summit
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static site in out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint (Next.js core-web-vitals and TypeScript rules) |
pnpm typecheck |
Generate route types (next typegen), then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/summit into demo/ (the freeframe.dev demo; you can delete it) |
To see the site as it looks during the event, add ?now= to any URL, for example
http://localhost:3000/schedule?now=2027-04-15T10:45 (see Event time).
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with countdown to doors open, topic marquee, three feature cards, six featured speakers, count-up stats, "A day at Summit" agenda, partner logos, testimonial, venue teaser, newsletter sign-up |
/speakers |
src/app/speakers/page.tsx |
All speakers with a track filter; each opens a profile dialog with bio and sessions |
/speaker?id=<id> |
src/app/speaker/page.tsx |
One speaker: portrait, role, bio, links, sessions, four more speakers |
/schedule |
src/app/schedule/page.tsx |
Day tabs plus "My schedule", track filter, stars, "On now" / "Up next" highlights |
/tickets |
src/app/tickets/page.tsx |
Four ticket cards with quantity steppers, order summary, demo checkout, compare table, FAQ |
/venue |
src/app/venue/page.tsx |
Venue facts, illustrated map, getting here, partner hotels (#hotels), accessibility (#accessibility) |
/sponsors |
src/app/sponsors/page.tsx |
Sponsors by tier, three sponsorship packages, enquiry form (#enquire) |
| 404 | src/app/not-found.tsx |
"This session has been cancelled" with links home and to the schedule |
Speaker profiles use one route with a query string (/speaker?id=mei-tanaka) rather than a dynamic segment, so the
whole site can be a static export. The header, footer, toast and back-to-top button are on every page.
Project structure#
src/
├── app/
│ ├── layout.tsx fonts, site metadata, header, footer, toast, reveal observer, back-to-top
│ ├── page.tsx home (DOORS_OPEN, TOPICS, FEATURES, FEATURED_SPEAKERS, AGENDA)
│ ├── <route>/page.tsx one folder per page (speakers, speaker, schedule, tickets, venue, sponsors)
│ ├── not-found.tsx 404 page
│ ├── globals.css Tailwind import, design tokens (@theme) and component classes
│ └── icon.svg favicon
├── components/
│ ├── Schedule.tsx schedule: day tabs, track filter, stars, live highlights
│ ├── Session.tsx SessionCard, SpeakerTile, TrackTag, delay()
│ ├── SpeakerGrid.tsx speaker grid, track filter and profile dialog
│ ├── SpeakerProfile.tsx speaker page (SpeakerFromUrl reads ?id=)
│ ├── TalkList.tsx a speaker's sessions as a compact list
│ ├── Tickets.tsx ticket cards, order summary, checkout and confirmation
│ ├── Countdown.tsx countdown to a target time
│ ├── NewsletterForm.tsx home page sign-up
│ ├── SponsorForm.tsx sponsorship enquiry
│ ├── FieldError.tsx inline error message for a field
│ ├── SiteHeader.tsx header and mobile menu
│ ├── SiteFooter.tsx footer
│ ├── PageHero.tsx header band of the inner pages
│ ├── Logo.tsx placeholder sponsor wordmarks
│ ├── VenueArt.tsx venue illustration (inline SVG)
│ ├── VenueMap.tsx Nordhavn map (inline SVG)
│ ├── Icon.tsx icon set (<Icon name="calendar" />)
│ └── CountUp, Toast, RevealObserver, BackToTop
├── data/
│ ├── summit.ts TRACKS, DAYS, SPEAKERS, SESSIONS, EARLY_BIRD_ENDS, TEAM_DISCOUNT, TICKET_PRICES
│ ├── tickets.ts TICKETS (cards), COMPARE, TICKETS_FAQ, COUNTRIES
│ ├── sponsors.ts TIERS, HOME_PARTNERS, PACKAGES
│ └── nav.ts header, mobile and footer links
├── fonts/ Inter and Space Grotesk (woff2) with their OFL licences
└── lib/
├── event.ts event time: eventTime(), eventNow(), eventDate(), speakerById(), talksFor()
├── client.ts useEventNow(), toast()
├── validation.ts useValidation(), message()
├── title.ts useDocumentTitle()
├── asset.ts asset(): prefixes public files with the base path
└── metadata.ts pageMetadata(): title, description and Open Graph per page
public/img/ speaker portraits, favicon.svg, og-image.svg and og-image.png
next.config.ts static export and optional base path
template.json template metadata used by freeframe.devCustomising#
Brand and colours#
Design tokens live in the @theme block at the top of src/app/globals.css:
| Token | Used for |
|---|---|
--color-night, --color-night-2 |
Page background and darker bands (marquee, table header, dialog) |
--color-panel |
Cards, sessions, ticket cards |
--color-edge |
Borders |
--color-ink, --color-haze |
Text and muted text |
--color-violet, --color-magenta, --color-flame |
The brand gradient (buttons, .gradient-text, .gradient-bg, glowing blobs) |
--color-lime |
Focus rings, "On now", pressed stars, the toast, eyebrows, checkmarks |
--color-sky |
"Up next" |
The gradient is defined once in .gradient-text, .gradient-bg, .btn-primary and .back-to-top, all from the
three stops. For example, a blue-to-teal brand:
@theme {
--color-violet: #6366f1;
--color-magenta: #3b82f6;
--color-flame: #14b8a6;
}Tailwind compiles in pnpm dev and pnpm build, so there's no separate CSS step. Some colours are literal values:
the track tag colours (.tag-design, .tag-engineering, .tag-ai, .tag-leadership, .tag-general) and
.btn-lime:hover in globals.css, the illustrations in VenueArt.tsx and VenueMap.tsx, the artwork in
public/img/ and src/app/icon.svg, and the browser theme colour (viewport.themeColor, #0a0814) in
src/app/layout.tsx.
The design is dark only: viewport.colorScheme is "dark" and there is no light theme or theme toggle.
Typography#
Headlines use Space Grotesk (--font-display, via font-display, .display and .h-section) and text uses
Inter (--font-sans). Both are self-hosted with next/font/local in src/app/layout.tsx, from the same Latin
files Google Fonts serves, so the visitor's browser never contacts Google:
const inter = localFont({ src: "../fonts/Inter-latin.woff2", variable: "--font-inter", weight: "400 800", display: "swap" });
const spaceGrotesk = localFont({ src: "../fonts/SpaceGrotesk-latin.woff2", variable: "--font-space-grotesk", weight: "500 700", display: "swap" });The files and their SIL Open Font License texts (Inter-OFL.txt, SpaceGrotesk-OFL.txt) are in src/fonts/. To swap
a font, add its .woff2 to src/fonts/ and point src at it (or import a family from next/font/google), keep the
same variable name, and update the fallback names in --font-display / --font-sans in globals.css. The SVG
illustrations name Space Grotesk and Inter in their fontFamily attributes too.
Content#
The programme and prices are typed data in src/data/summit.ts. Times are local event time (Copenhagen, UTC+2 in
April):
| Export | What it holds | Used by |
|---|---|---|
TRACKS |
Track ids and labels: design, engineering, ai, leadership, general |
Track tags, speaker and schedule filters |
DAYS |
id (wed, thu, fri), date (YYYY-MM-DD), label ("Thu 15 Apr"), title ("Conference day 1") |
Schedule tabs, session days, today's tab |
SPEAKERS |
id, name, role, company, city, track, bio, twitter, site |
Speaker grid, dialog, speaker pages, session cards |
SESSIONS |
id, day (a DAYS id), start/end ("HH:MM"), title, speakers (speaker ids), track, room, level, kind (break, keynote, panel, social, talk, workshop) |
Schedule, home agenda, speaker sessions |
EARLY_BIRD_ENDS |
When early-bird prices end ("2026-12-31T23:59:59+01:00") |
Ticket prices |
KEEP_DEMO_CURRENT |
true in the demo: once the edition is over, every date moves on a year (see Demo dates). Set it to false when you enter your real dates |
Every date and the edition year |
TEAM_DISCOUNT |
{ minTickets: 5, percent: 15 } |
Order summary |
TICKET_PRICES |
id, name, price, early per ticket |
Ticket cards and order summary |
src/data/tickets.ts holds the ticket cards (TICKETS: id, name, text, features, optional popular for the
gradient border and "Most popular" tag, optional fixedNote for a pass without an early-bird discount), the compare
table (COMPARE: one row per feature with one true/false per card, in TICKETS order), TICKETS_FAQ and the
checkout COUNTRIES. src/data/sponsors.ts holds the sponsor TIERS (name, tile height, text size, grid classes and
sponsors with a LogoShape), HOME_PARTNERS (the home page strip) and the sponsorship PACKAGES.
To add a session, add an object to SESSIONS:
{id:"s36",day:"fri",start:"15:40",end:"16:20",title:"Shipping accessible motion",speakers:["jonas-lind"],track:"design",room:"Hall B",level:"Intermediate",kind:"talk"},Keep id unique and stable: it's what visitors' starred sessions refer to. track must be a TRACKS key; day and
speakers are plain strings, so check they match a DAYS id and SPEAKERS ids. Sessions show in array order within
a day. Breaks and socials have no star button; use track: "general" for them so they show under every track filter.
To add a speaker, add an entry to SPEAKERS (its id is used in /speaker?id= and must be URL-safe), add a square
portrait at public/img/speakers/<id>.svg, and list the id in the speakers of their sessions. The grid, dialog,
speaker page and session cards pick it up automatically. The six home page speakers are FEATURED_SPEAKERS in
src/app/page.tsx, and the home agenda is AGENDA (session ids) in the same file.
To add a ticket type, add it to TICKET_PRICES and a card with the same id to TICKETS (a card without a price is
not shown), and add a value for it to every COMPARE row.
Some content is written directly in pages and components and must be edited there:
- the home page hero (dates, location, intro),
DOORS_OPEN,TOPICS(marquee),FEATURES, the four count-up stats, the testimonial and the venue teaser insrc/app/page.tsx; FACTS,TRAVEL,HOTELSandACCESSinsrc/app/venue/page.tsx, plus the access email address;- the early-bird copy: "until 31 December" in
src/app/tickets/page.tsxand "Early bird · ends 31 Dec" inTickets.tsx(written as<EventDate iso={EARLY_BIRD_ENDS} … />, so they follow it), the VAT note, the name-change date and "Good to know"; - the package options (
TIERS) inSponsorForm.tsx; - the dates and venue line in the mobile menu (
SiteHeader.tsx), and the footer text, email, social and legal links inSiteFooter.tsx; - the "Copenhagen time (CEST)" note in
src/app/schedule/page.tsx, and page titles and descriptions in each page'smetadata.
Images and artwork#
| Files | Size (aspect ratio) | Where |
|---|---|---|
public/img/speakers/<id>.svg |
400 × 400 (1:1) | Speaker tiles, dialog, speaker pages, session cards |
public/img/og-image.svg |
1200 × 630 | Source of the social image |
public/img/og-image.png |
1200 × 630 | Social image used by pageMetadata() |
public/img/favicon.svg |
64 × 64 | Header and footer logo (copy of src/app/icon.svg) |
src/app/icon.svg |
64 × 64 | Favicon (Next.js file convention) |
The venue illustration (VenueArt.tsx), the map (VenueMap.tsx), the 404 and the sponsor marks (Logo.tsx) are
inline SVG or CSS. After editing og-image.svg, export it again as a 1200 × 630 og-image.png.
Always reference files in public/ through asset(), so they keep working when the site is served from a sub-folder:
<img src={asset(`/img/speakers/${s.id}.svg`)} alt="" width={400} height={400} className="size-full object-cover" />To use photos, add square .jpg/.webp files and change the /img/speakers/${...}.svg paths in Session.tsx,
SpeakerGrid.tsx and SpeakerProfile.tsx (and the testimonial portrait in src/app/page.tsx). For real sponsor
logos, replace <Logo> in src/app/page.tsx and src/app/sponsors/page.tsx with an <img> of each logo. The site
uses plain <img> on purpose: with a static export, next/image can't optimise images, so the
@next/next/no-img-element lint rule is turned off in eslint.config.mjs.
Navigation, header and footer#
- Header links are
NAVinsrc/data/nav.ts; the mobile menu usesMOBILE_NAV(Home, theNAVlinks and Tickets).SiteHeader.tsxrenders them and marks the current page witharia-current="page"(a speaker page counts as Speakers). The Get tickets button is inSiteHeader.tsx. - The footer link columns are
FOOTER_EVENTandFOOTER_INFOin the same file; the contact column is inSiteFooter.tsx. - To add a page, create
src/app/<route>/page.tsx, exportmetadata = pageMetadata(title, description), start it with<PageHero>for the same look, and add aNAVor footer entry if it belongs in a menu.
Features#
Event time and the ?now= preview#
Every time in the data is event time, not the visitor's: src/lib/event.ts turns "2027-04-15T10:30" into a moment
with eventTime(), which appends EVENT_OFFSET ("+02:00"). eventDate() gives today's date at the event (UTC+2),
so a visitor in New York late in the evening still gets the right day.
eventNow() returns the current time, or the time in the ?now=YYYY-MM-DDTHH:MM parameter, so you can preview the
live site on any page:
/?now=2027-04-13T20:00: the countdown shows 0 days and 13 hours to go;/schedule?now=2027-04-15T10:45: Thursday opens with three talks "On now" and the coffee break "Up next";/tickets?now=2027-01-05T12:00: full prices after the early bird ends.
The static HTML can't know the time, so useEventNow() in src/lib/client.ts returns null on the server and
during hydration, then the event time in the browser, updated every minute (or every second with
useEventNow("second")). The countdown, the schedule's default day, the live highlights and the early-bird prices
are all filled in after hydration and never cause a hydration mismatch.
To move the event, change the DAYS dates, DOORS_OPEN in src/app/page.tsx and EARLY_BIRD_ENDS. For another
time zone, change EVENT_OFFSET and the 2 * 3600 * 1000 offset in eventDate() (and the offsets in DOORS_OPEN
and EARLY_BIRD_ENDS). The offset is fixed, so pick the one in force during the event.
Demo dates#
The demo is written for 14–16 April 2027. While KEEP_DEMO_CURRENT in src/data/summit.ts is true and that
edition is over (the event-local date is after the last of DAYS), every date moves to the same week of the next year,
by whole weeks so weekdays never change (Wed 14 April 2027 becomes Wed 12 April 2028), so the countdown, ticket prices,
schedule and live highlights always point at an upcoming conference. The year of the edition follows.
src/lib/edition.ts works out the shift; useEdition() and the components in src/components/Edition.tsx
(<EditionYear>, <EditionDates>, <EventDate>, <EditionText>, <DayLabel>, <EarlyOnly>, <EditionTitle>)
apply it after hydration, so the static build shows the dates as written and there is no hydration mismatch. Before
the edition is over nothing changes, so ?now=2027-04-15T10:45 still previews the live schedule. Set
KEEP_DEMO_CURRENT = false once you enter your real dates. <EarlyOnly> (the "Early-bird tickets on sale" pill and
the first sentence of the tickets intro) hides its content once early-bird pricing has ended, whatever the setting.
Countdown#
Countdown.tsx shows days, hours, minutes and seconds until its target; the home page passes DOORS_OPEN
("2027-04-14T09:00:00+02:00"). It ticks every second, stops at zero, and shows 000 00 00 00 before hydration.
It has role="timer" and an aria-label, and isn't a live region, so screen readers aren't interrupted every second.
Speaker filter and profiles#
SpeakerGrid.tsx on /speakers shows every speaker in SPEAKERS. Filter buttons (aria-pressed, one per track
except General) show the speakers of one track; a polite live region announces the count ("3 speakers").
Each tile is a button (aria-haspopup="dialog") that opens a native <dialog> with the portrait, role, bio, the
speaker's sessions (TalkList, from talksFor()) and a Full profile link. Focus moves to the close button; Esc,
the close button or a click on the backdrop closes it, and focus returns to the tile. With reduced motion the dialog
doesn't animate in.
Speaker pages#
/speaker is one static page. src/app/speaker/page.tsx renders SpeakerFromUrl inside <Suspense> with
<SpeakerProfile speaker={null} /> as the fallback, so the exported HTML contains the full profile of the first
speaker in SPEAKERS (Amara Osei). In the browser SpeakerFromUrl reads ?id= with useSearchParams() and shows
that speaker in the same layout as the first (reveal animation, website icon, session cards), with four more
speakers below.
speakerById() in src/lib/event.ts looks ids up in a Map in lower case, so ids match in any letter case
(?id=Mei-Tanaka works like ?id=mei-tanaka), and values like constructor or __proto__ find nothing; anything
else shows the first speaker.
useDocumentTitle() in src/lib/title.ts sets the title to "<name> — Speaker at Summit 2027" and re-applies it if
Next.js rewrites the head.
The Twitter and website pills link to #; point them at the real profiles in SpeakerProfile.tsx.
Schedule and tracks#
Schedule.tsx on /schedule renders SESSIONS with day tabs for each of DAYS plus My schedule:
- It opens on the tab in the URL hash (
/schedule#thu;#wed,#thu,#frior#mine), otherwise on today during the event (fromeventDate()), otherwise on the first day. Choosing a tab (by click or arrow key) updates the hash withhistory.replaceState, so the URL can be shared. - The tabs follow the ARIA tabs pattern: Left/Right arrows move between tabs (wrapping round), Home and End jump to the ends, and only the selected tab is in the tab order.
- Track filter buttons (
aria-pressed) show one track. General sessions (breaks, meals, socials) always stay visible; if a day has no talks in that track, "No sessions in this track today" is shown. - Session cards (
SessionCardinSession.tsx) show time, room, track and kind tags, level, title and speaker links. Track tags are text, so tracks don't rely on colour.
My schedule (stars)#
Every talk, keynote, panel and workshop has a star button (aria-pressed; its label reads "Add “…” to my schedule",
or "Remove “…” from my schedule" once starred). Starring adds the session id to the
visitor's list (see Saved data) and shows a toast ("Added to my schedule"). The My
schedule tab shows a count and lists the starred sessions grouped by day, or an empty state. Stars are shown after
hydration, because the server render has no access to localStorage.
Live highlights#
While the event is on, liveState() in Schedule.tsx marks sessions running now (.is-now, a lime border and
"● On now") and the next sessions to start within three hours (.is-next, "Up next"), and shows a "Live now" note
above the list. It runs every minute; change 3 * 3600 * 1000 in liveState() to widen or narrow the "Up next"
window. Preview it with ?now= as described above.
Tickets and order summary#
Tickets.tsx renders a card for each entry in TICKETS that has a price in TICKET_PRICES:
- Until
EARLY_BIRD_ENDSeach card shows itsearlyprice with the fullpricestruck through and an early-bird badge; afterwards it showsprice. Before hydration the early-bird prices are shown, as in the static HTML. Cards withfixedNote(the student pass) show the note instead of a was-price. - Steppers set 0 to 20 tickets per type (
MAX_QTY); the quantity is an<output>in a polite live region, and a card with tickets gets a lime ring. - The order summary lists each line, the subtotal, the team discount when the order has
TEAM_DISCOUNT.minTicketsor more tickets ("Team discount (15%)"), and the total. A hint says how many more tickets unlock the discount. Amounts are whole euros (euro()); prices are shown as including VAT, nothing is added. - Continue to checkout (disabled while the order is empty) reveals the checkout form, scrolls to it (smoothly, unless reduced motion is on) and focuses the first name. After a valid order the confirmation replaces it, shows the total and receives focus.
Toasts#
toast(message) from src/lib/client.ts works from any client component: it dispatches a toast event that the
<Toast /> in the layout shows for about two and a half seconds in a polite live region.
Header, mobile menu, reveals and counters#
SiteHeader.tsx turns the header solid after scrolling. The menu button toggles the full-screen mobile menu
(aria-expanded, focus moves to the first link, Esc closes it and returns focus to the button, the page behind
doesn't scroll, and it closes on navigation or on wide screens). RevealObserver.tsx fades in .reveal elements as
they scroll into view, revealing everything above too; with reduced motion, or before JavaScript runs (the layout's
script swaps no-js for js), content is visible straight away. Use style={delay(70)} from Session.tsx to
stagger them. CountUp.tsx counts the home stats up once when visible; the final value is in the HTML, and with
reduced motion it isn't animated. The topic marquee is duplicated for a seamless loop (the copy is aria-hidden),
pauses on hover and stops with reduced motion, as do the drifting background blobs.
Back to top#
BackToTop.tsx, mounted once in the layout, shows a button after about one screen of scrolling. It scrolls to the top
(instantly with reduced motion), moves focus to <main>, lifts above the toast and elements marked
data-back-to-top-avoid, and hides while it would cover an element marked data-back-to-top-hide. To remove it,
delete <BackToTop /> from the layout and the .back-to-top block at the end of globals.css.
Forms#
| Form | Where | Validates |
|---|---|---|
Newsletter (data-form="newsletter") |
NewsletterForm.tsx, home page |
|
Sponsor enquiry (data-form="sponsor") |
SponsorForm.tsx, /sponsors#enquire |
Company, name, work email, package, message of at least 20 characters |
Checkout (data-form="checkout") |
Tickets.tsx, /tickets |
First and last name, email, country, terms checkbox (company is optional; payment is a radio, card by default) |
Validation comes from useValidation(form) in src/lib/validation.ts: spread field(name) on an input and render
<FieldError form="sponsor" name="company" message={v.errors.company} /> after it (after the label for a checkbox). It uses the fields' own HTML constraints
(required, type="email", minLength), shows messages from message() when a field with a value loses focus and on
submit, links them with aria-describedby/aria-invalid (error ids are <form>-<name>-error), and validateForm()
focuses the first invalid field. Success messages are role="status".
Nothing is sent until you connect the forms; the success messages and the checkout confirmation say so. The newsletter and sponsor handlers mark the place:
// To send for real: await fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })To post to Formspree (or a similar service), make the handler async and replace that comment.
In SponsorForm.tsx:
onSubmit={async (e) => {
e.preventDefault();
const form = e.currentTarget;
if (!v.validateForm(form)) return;
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
setDone(true);
form.reset();
}}Do the same in NewsletterForm.tsx (or send the address to your email provider). Every validated field has a
name, so new FormData(form) includes it. Update the success messages, which currently say the form is a demo.
The checkout should hand the order to a ticketing or payment provider (for example Tito or Stripe Checkout) at the
comment in its onSubmit in Tickets.tsx. The quantities are in the qty state, not the form, so add them yourself,
for example data.append("order", JSON.stringify(qty)) on a FormData. Never trust prices sent from the browser:
work out the total again on the server or in the provider.
If you remove the static export (see Building and deploying), you can post to your own
Route Handler instead (for example src/app/api/sponsor/route.ts exporting POST), or turn a form into a
Server Action. Validate again on the server either way.
Saved data (localStorage)#
| Key | Shape |
|---|---|
summit-2027-stars (STAR_KEY in Schedule.tsx) |
Array of session ids, for example ["s11", "s21"] |
readStars() in Schedule.tsx validates the value on every read: anything that isn't an array of known, unique
session ids is dropped and the stored copy is repaired, so corrupt or tampered data can't break the schedule. If
storage is unavailable (blocked by the browser), reads return an empty list and starring has no effect, but nothing
breaks. writeStars()
saves the list, and a summit-stars-change event keeps the tabs, count and buttons in sync.
Stars are per browser and per device. To reset them, unstar the sessions or run
localStorage.removeItem("summit-2027-stars") in the browser console. If you remove or rename a session id, old
stars for it are ignored and cleaned up. For a new edition, change STAR_KEY so last year's stars don't carry over.
SEO and sharing#
- Site-wide defaults are
metadatainsrc/app/layout.tsx. Each page exportsmetadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which sets the title as is, the description, Open Graph tags (site name "Summit 2027", imageasset("/img/og-image.png")) and a large Twitter card. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tohttps://example.com). Set it when you build so social images get absolute URLs:
NEXT_PUBLIC_SITE_URL=https://www.your-event.com pnpm build/speakeris a single static page, so its HTML metadata is the first speaker's; the title for other ids is set in the browser. Search engines that don't run JavaScript only see the default. If speaker pages matter for search, switch to a dynamic route (/speakers/[id]) withgenerateStaticParams().
For launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and
Event structured data (name, dates, location, offers) as JSON-LD on the home page.
Accessibility#
Built in:
- a "Skip to content" link (in
layout.tsx), labelled landmarks and oneh1per page; - visible lime focus rings (
:focus-visibleinglobals.css); - the ARIA tabs pattern for schedule days, a native
<dialog>with focus return for speaker profiles,aria-pressedfilters and stars,aria-currentin the navigation,role="timer"on the countdown; - track and kind shown as text tags, and "On now" / "Up next" as text, not by colour alone;
- form errors linked with
aria-describedby/aria-invalidand focus moved to the first error; focus moved to the checkout and to the order confirmation; - live regions for the speaker count, ticket quantities, success messages and toasts;
- compare table with row and column headers and "Included" / "Not included" for screen readers;
- reduced motion respected by reveals, counters, the marquee, background blobs, the dialog, smooth scrolling and the back-to-top button;
- WCAG AA contrast for text on the dark background.
When editing, keep one h1 per page, label every input, keep gradient text large, and leave portrait alt empty only
where the speaker's name is right next to it.
Building and deploying#
pnpm build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/summit pnpm build. next.config.ts
passes it to basePath and to NEXT_PUBLIC_BASE_PATH, which asset() uses. pnpm build:demo does this for the
freeframe.dev demo and writes demo/, which you can delete.
Need server features (a Route Handler for forms or checkout, Server Actions, middleware)? Remove
output: "export" and the images.unoptimized setting from next.config.ts and deploy to a Node-capable host such
as Vercel. Everything else keeps working.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and
typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain
still need the TypeScript 6 compiler API. Once typescript-eslint supports TypeScript 7, replace both with a single
typescript@7. eslint.config.mjs also pins settings.react.version, because eslint-plugin-react can't detect it
under ESLint 10.
Troubleshooting#
- A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source.
Write
"bg-violet/20 text-violet", not`bg-${color}/20`(as thetonevalues inFEATURESand thetile,textandgridvalues inTIERSdo), and inclassNametemplate literals keep a space before each${...}(as in`track-tag tag-${track}`and`mt-6 grid gap-3 ${tier.grid}`) so class names don't run together. - Images break when deployed to a sub-folder. Build with
DEMO_BASE_PATHset, and reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews use
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. - The schedule shows no "On now" and the countdown is still running. The event hasn't started yet. Add
?now=2027-04-15T10:45to the URL to preview the live state; remove it to go back to the real time. - Session times or today's tab look wrong for me. Times are event time, not the visitor's: a visitor in another
time zone sees Copenhagen times. Change
EVENT_OFFSETandeventDate()insrc/lib/event.tsfor another zone. - A speaker page shows Amara Osei instead of the one I linked. The
?id=value must be aSPEAKERSid (any letter case, with dashes). - Starred sessions vanished, or old ones won't go away. Stars are stored per browser; private windows and cleared
site data reset them. Unstar them or run
localStorage.removeItem("summit-2027-stars")to start over. - Forms "send" but nothing arrives. Connect them as described in Forms.
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG speaker portraits, venue illustration and map. Inter and Space Grotesk are licensed under
the SIL Open Font License (src/fonts/). Summit 2027, its speakers, sponsors, venue and hotels are fictional.