Freeframe

Documentation

Summit

v1.0.3Updated Oct 17, 2026A bold conference website with speakers, a starrable schedule and ticket checkout.

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.

sh
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.dev

Customising#

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:

css
@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:

tsx
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:

ts
{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 in src/app/page.tsx;
  • FACTS, TRAVEL, HOTELS and ACCESS in src/app/venue/page.tsx, plus the access email address;
  • the early-bird copy: "until 31 December" in src/app/tickets/page.tsx and "Early bird · ends 31 Dec" in Tickets.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) in SponsorForm.tsx;
  • the dates and venue line in the mobile menu (SiteHeader.tsx), and the footer text, email, social and legal links in SiteFooter.tsx;
  • the "Copenhagen time (CEST)" note in src/app/schedule/page.tsx, and page titles and descriptions in each page's metadata.

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:

tsx
<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.

  • Header links are NAV in src/data/nav.ts; the mobile menu uses MOBILE_NAV (Home, the NAV links and Tickets). SiteHeader.tsx renders them and marks the current page with aria-current="page" (a speaker page counts as Speakers). The Get tickets button is in SiteHeader.tsx.
  • The footer link columns are FOOTER_EVENT and FOOTER_INFO in the same file; the contact column is in SiteFooter.tsx.
  • To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata(title, description), start it with <PageHero> for the same look, and add a NAV or 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, #fri or #mine), otherwise on today during the event (from eventDate()), otherwise on the first day. Choosing a tab (by click or arrow key) updates the hash with history.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 (SessionCard in Session.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_ENDS each card shows its early price with the full price struck through and an early-bird badge; afterwards it shows price. Before hydration the early-bird prices are shown, as in the static HTML. Cards with fixedNote (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.minTickets or 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 Email
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:

tsx
// 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:

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 metadata in src/app/layout.tsx. Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which sets the title as is, the description, Open Graph tags (site name "Summit 2027", image asset("/img/og-image.png")) and a large Twitter card.
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to https://example.com). Set it when you build so social images get absolute URLs:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-event.com pnpm build
  • /speaker is 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]) with generateStaticParams().

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 one h1 per page;
  • visible lime focus rings (:focus-visible in globals.css);
  • the ARIA tabs pattern for schedule days, a native <dialog> with focus return for speaker profiles, aria-pressed filters and stars, aria-current in 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-invalid and 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 directory out.
  • 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 the tone values in FEATURES and the tile, text and grid values in TIERS do), and in className template 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_PATH set, and reference public files with asset() (links made with next/link get the base path automatically).
  • Social previews use example.com. Set NEXT_PUBLIC_SITE_URL when building.
  • The schedule shows no "On now" and the countdown is still running. The event hasn't started yet. Add ?now=2027-04-15T10:45 to 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_OFFSET and eventDate() in src/lib/event.ts for another zone.
  • A speaker page shows Amara Osei instead of the one I linked. The ?id= value must be a SPEAKERS id (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.

Search documentation

Search the guides and every template's documentation