Overview#
Lingua is a complete website for a language school that teaches adults in small groups, in person and live online. It's shown as Lingua, a fictional school at 14 Harbour Lane in Bristol teaching Spanish, French, Italian, German, Japanese and Portuguese. It has a course list with filters, course pages with a syllabus and teacher, an interactive placement test that recommends a course, a weekly timetable, a teacher directory, fees in tabs and a four-step enrolment form with a live fee summary. 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.
Live demo: freeframe.dev/templates/lingua
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/lingua into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with greeting bubbles, six language cards, how it works, in person and online, three featured courses, student quotes, teachers, placement call to action |
/courses |
src/app/courses/page.tsx |
Every course with filters by language, level and format (kept in the URL), and the CEFR levels explained |
/course?id=<id> |
src/app/course/page.tsx |
One course: facts, tabs (overview, syllabus, teacher), fee card with places left, related courses |
/placement |
src/app/placement/page.tsx |
Ten-question placement test with a level and recommended courses |
/teachers |
src/app/teachers/page.tsx |
Eight teachers with a language filter and the courses each teaches |
/timetable |
src/app/timetable/page.tsx |
A week of classes (Monday to Saturday) with filters by language, format and time of day |
/enrol |
src/app/enrol/page.tsx |
Fees in tabs (group, private, companies) and the four-step enrolment form (#enrol) |
/about |
src/app/about/page.tsx |
Story and numbers, teaching method, the building, FAQ |
/contact |
src/app/contact/page.tsx |
Contact form with topics, map, address, reception hours, getting here |
| 404 | src/app/not-found.tsx |
"Lost in translation" page with links home and to the courses |
Project structure#
src/
app/
layout.tsx <html>, fonts, skip link, header, footer, reveal observer, back-to-top
globals.css Tailwind v4 import, @theme tokens and component classes
page.tsx home; every other route has its own folder with page.tsx
not-found.tsx 404 page
icon.svg favicon
opengraph-image.png default social image
fonts/ Lexend and Young Serif (woff2) and their licenses
components/
SiteHeader.tsx header, desktop navigation, mobile menu
SiteFooter.tsx footer and newsletter form
CourseCard.tsx the course card used on every page that lists courses
CourseBrowser.tsx course list with filters (/courses)
CourseView.tsx course page, reading ?id= (/course)
Placement.tsx placement test (/placement)
TeacherBrowser.tsx teacher list with language filter (/teachers)
Timetable.tsx week view with filters (/timetable)
PriceTabs.tsx fees in tabs (/enrol)
EnrolForm.tsx four-step enrolment with fee summary (/enrol)
TopicPrefill.tsx ?topic= for the contact form
ValidatedForm.tsx contact and newsletter forms
Icon.tsx line icons
RevealObserver.tsx scroll reveals
BackToTop.tsx back-to-top button
Year.tsx current year in the footer
data/
school.ts languages, levels, teachers, courses, syllabus, outcomes, placement questions, prices
fees.ts the price cards on /enrol
nav.ts header links, phone and email
hooks/useTabs.ts ARIA tabs (keyboard and #hash support)
lib/
school.ts formatting, URL parameters, storage, placement scoring, enrolment draft and fees
metadata.ts pageMetadata(title, description)
asset.ts asset(path): prefixes public files with the base path
public/img/ logo, illustrations, teacher portraits (teachers/), social imageCustomising#
Brand and colours#
The design tokens are in the @theme block at the top of src/app/globals.css:
| Token | Use |
|---|---|
--color-paper, --color-paper-2 |
Page background and panels |
--color-ink |
Text, outlines, the dark sections and the offset "sticker" shadows |
--color-muted |
Secondary text |
--color-brand, --color-brand-dark, --color-brand-soft |
Ultramarine buttons, links and highlights |
--color-tomato, --color-tomato-ink |
Accent (decorative) and accent text |
--color-sun, --color-mint |
Highlights with ink text |
--color-es … --color-pt (+ -ink, -solid) |
One colour pair per language, used by the tone-es … tone-pt classes |
Change a token and every utility that uses it follows:
@theme {
--color-brand: #0f766e;
--color-brand-dark: #115e59;
}Component classes (btn-primary, card, chip, bubble, level-badge, choice, tab, field...) are defined
below the tokens. A language's colour comes from its tone in LANGS ("es" uses .tone-es); add a .tone-xx rule
and --color-xx tokens for a new language.
Typography#
Two fonts are used: Young Serif (headings, font-display) and Lexend (text, font-sans). Their latin files
are bundled in src/app/fonts/ (with their SIL Open Font License texts) and loaded with next/font/local in
src/app/layout.tsx, so visitors make no requests to Google and the pages look exactly like the HTML version. To
swap a font, put its .woff2 file in src/app/fonts/ and change src (and weight) in layout.tsx, or use
next/font/google instead; keep the variable names (--font-young-serif, --font-lexend) or update them in
globals.css.
Content#
Almost everything the pages list comes from src/data/school.ts:
| Export | What it holds |
|---|---|
LANGS |
id, two-letter code, name, greeting (hello), colour tone, card blurb |
LEVELS |
CEFR levels a1 to c1 with a short description |
TEACHERS |
id (also the portrait file name), name, role, langs, from, years, bio, quote |
KEEP_DEMO_CURRENT, TERM |
true and { starts: "2027-01-11", ends: "2027-03-26" }: the term the courses belong to. Once it has started, the browser moves the term and every course start on by whole weeks to the same term next year (see below). Set it to false once COURSES and TERM hold your real dates |
COURSES |
Every course (see below) |
SYLLABUS, OUTCOMES |
Five modules and four outcomes per level, shared by every language |
PLACEMENT, ANSWERS, PLACEMENT_BANDS |
The placement test (see Placement test) |
BOOK_PRICE, CONCESSION |
Course book price (pounds) and the concession discount (0.15) |
A course:
{ id: "fr-a1-evening", lang: "french", level: "a1", format: "in-person", title: "French for Beginners",
days: [0, 2], start: "18:30", end: "20:00", weeks: 10, starts: "2027-01-11", fee: 295,
teacher: "julien-mbaye", seats: 5, room: "Room 3",
summary: "Start speaking French with confidence, two evenings a week.", description: "…" }days are 0 (Monday) to 5 (Saturday); starts is the first class as YYYY-MM-DD; fee is in pounds; seats is the
places left ("Only 3 places left" shows in the accent colour from 3 down). Add an object to COURSES and the course
appears in the course list, the timetable, the enrolment form, its teacher's card and the placement test's
recommendations, with its page at /course?id=<id>.
Demo dates. The demo is written for the winter term in TERM (11 January to 26 March 2027). While
KEEP_DEMO_CURRENT is true and that term has started, the term and every course's starts move forward to the same
term the next year, by whole weeks so weekdays never change (11 January 2027 becomes 10 January 2028), so visitors
never see a course that has already begun. termShift() in src/lib/term.ts works out the shift; <TermDate> and
<TermYear> (src/components/TermDate.tsx) and the useTermShift() hook apply it after hydration, so the static
build shows the dates as written. Set KEEP_DEMO_CURRENT = false when you enter your real term dates.
Hand-written content: the home page's featured courses (FEATURED), quotes (QUOTES) and teachers (TEAM) at the top
of src/app/page.tsx; the price cards in src/data/fees.ts; the text of the about and contact pages in their
page.tsx.
Images and artwork#
All artwork is SVG in public/img/: hero-class.svg (1200×1000), in-person.svg and online.svg (1200×800),
school.svg and map.svg (1200×900), and the teacher portraits teachers/<teacher id>.svg (600×500, shown at 6:5).
To use photos, keep those aspect ratios (or change the aspect-[…] class on the wrapping .art element, which crops
with object-fit: cover). The logo is public/img/logo.svg, the favicon src/app/icon.svg. The social image is
src/app/opengraph-image.png (1200×630), also copied to public/img/og-image.png for pageMetadata(); its source is
public/img/og-image.svg.
Navigation, header and footer#
Header links are NAV in src/data/nav.ts; match lists the routes that mark a link as the current page (the course
page highlights "Courses"). MOBILE_NAV adds Home and Fees & enrolment to the mobile menu. The footer's link columns
are the LEARN and SCHOOL_LINKS arrays in SiteFooter.tsx; the phone and email are in SCHOOL in nav.ts.
Features#
Course filters#
CourseBrowser.tsx filters COURSES by language, level and format. The choices are written to the URL
(/courses?lang=french&format=online) with history.replaceState, and read from it on load: names and values are
matched in any case (?LANG=French), and only the template's own values are accepted. A live count says how many
courses are shown, and an empty state suggests the placement test. "Clear filters" resets all three.
Course page#
/course?id=<id> shows one course (CourseView.tsx). CourseFromParams reads id (any case) inside a <Suspense>
boundary; an unknown or missing id shows the first course, which is also what the static HTML contains. The page sets
document.title, and the "Enrol on this course" link opens /enrol?course=<id>#enrol.
Tabs#
The course page (overview, syllabus, teacher) and the fees (group, private, companies) use useTabs() from
src/hooks/useTabs.ts, the ARIA tabs pattern: arrow keys move between tabs, Home and End jump to the ends, and only
the selected tab is in the tab order. A hash with the panel's name opens that tab and scrolls to it, for example
/enrol#private or /course?id=fr-c1-culture#syllabus.
Placement test#
Placement.tsx asks for a language, then ten can-do statements (PLACEMENT), each answered "Not yet" (0), "With some
effort" (1) or "Easily" (2). The total (0–20) gives the level: PLACEMENT_BANDS = [5, 9, 13, 17] are the scores needed
for A2, B1, B2 and C1 (placementLevel() in src/lib/school.ts). recommend() then lists that language's courses
at the level, or else at the nearest level (the lower one first when two are as close), and the result explains when
there's no exact match.
Each step must be answered before Next; Back keeps the answers, focus moves to each new question, and a progress bar
shows the step. The result is saved (see Saved data) and shown again on the next visit until
"Start again". /placement?lang=german starts a new test with that language chosen.
Timetable#
Timetable.tsx lays out a normal week from COURSES (one entry per class day, in time order) and filters it by
language, format and time of day: mornings (start before 12:00), lunchtimes (before 17:00), evenings and Saturdays, as
decided by timeOfDay(). Filters are kept in the URL like the course list, days without a match say "No classes", and
each class links to its course.
Teacher filter#
TeacherBrowser.tsx shows the teachers who teach the chosen language (toggle buttons with aria-pressed), with a
live count; /teachers?lang=spanish selects a language.
Enrolment#
EnrolForm.tsx is a four-step form: course, your details, options (payment plan, concession, course book, how you
heard about us) and review with terms. Each step is validated before moving on, with messages under the fields and
focus on the first problem; going back keeps everything. The summary beside the form updates live from
enrolQuote(): course fee, 15% concession, course book, total, and with instalments three monthly payments that add
up to the penny. /enrol?course=<id> (any case) chooses the course. The confirmation clearly says that nothing was
sent; see Forms to connect it.
Header, mobile menu and scroll reveals#
SiteHeader.tsx adds a border after scrolling (is-scrolled), and its menu button toggles the mobile menu
(aria-expanded, Esc closes it and returns focus, and it closes on navigation). RevealObserver.tsx fades in .reveal
elements as they scroll into view, revealing everything above too, and picks up elements added later (filtered lists,
the placement result). With reduced motion, or before JavaScript runs (the js class is set by a small script in the
layout), content is visible straight away. The greeting bubbles on the home page bob gently (.bob), except with
reduced motion.
Back to top#
BackToTop.tsx, mounted once in the layout, shows a button after about one screen of scrolling. It scrolls smoothly
(instantly with reduced motion), moves focus to <main>, and hides while it would cover a sticky summary (elements
marked data-back-to-top-hide) or while the mobile menu is open.
Forms#
| Form | Where | Validates |
|---|---|---|
| Placement test | Placement.tsx |
An answer on every step |
| Enrolment | EnrolForm.tsx |
Course; first and last name, email, optional phone, experience; terms |
Contact (data-form="contact") |
src/app/contact/page.tsx |
Name, email, message of at least 20 characters |
Newsletter (data-form="newsletter") |
SiteFooter.tsx |
The contact and newsletter forms use ValidatedForm.tsx: native constraints (required, type="email", minLength)
are checked on blur and submit, errors appear in the element named by each field's aria-describedby, focus moves to
the first error, and the element marked data-form-success is shown. Nothing is sent until you connect it.
To post to a form service such as Formspree, replace the comment in onSubmit():
// To send for real: fetch("https://formspree.io/f/your-id", { method: "POST", body: new FormData(form) })
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(make onSubmit async), then keep the existing reset and success code, and update the success messages, which say
the form is a demo. The enrolment form has the same comment in its onSubmit(): send the draft (or
new FormData(event.currentTarget)) to your booking system or payment provider before showing the confirmation.
If you remove the static export, you can post to your own Route Handler instead (for example
src/app/api/enrol/route.ts exporting POST), or turn the forms into Server Actions. Always re-check the course,
places and price on the server.
Saved data (localStorage)#
| Key | Shape |
|---|---|
lingua:enrol:v1 (DRAFT_KEY) |
{ course, first, last, email, phone, experience, plan, concession, book, heard } |
lingua:placement:v1 (PLACEMENT_KEY) |
{ lang, answers }, answers being ten numbers 0–2 |
The enrolment draft is saved on every change and read with readDraft() in src/lib/school.ts, which runs
sanitizeDraft(): the course must be one of COURSES, text is stripped of control characters and cut to the field's
length, experience, plan and heard must be known values and the two options must be true or false. Invalid
JSON is removed and a repaired draft is written back, so storage never keeps values the page ignores. The draft is
cleared after a confirmed enrolment. The terms checkbox is never saved.
The placement result is checked by sanitizePlacement() (a known language and exactly ten answers of 0, 1 or 2) and
removed when it fails. Reset either by hand with localStorage.removeItem("lingua:enrol:v1") or
localStorage.removeItem("lingua:placement:v1").
SEO and sharing#
- Site-wide defaults (title, description, Open Graph) are in
metadatainsrc/app/layout.tsx. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which also sets the Open Graph imageasset("/img/og-image.png"). The 404 page also asks search engines not to index it. 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-school.com pnpm buildFor launch, consider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export), and
LanguageSchool and Course structured data as JSON-LD.
Accessibility#
Built in:
- a "Skip to content" link (in
layout.tsx) and landmarks on every page; - visible focus styles (
:focus-visibleinglobals.css) and oneh1per page; - form errors linked with
aria-describedby/aria-invalid, with focus moved to the first error; - ARIA tabs,
aria-currentfor steps and navigation,aria-pressedfilter toggles, radio and checkbox cards that are real inputs inside their labels; - live regions for filter counts, the course details and the fee summary;
- focus moved to each new placement question, enrolment step and result;
- reduced motion respected by reveals, smooth scrolling, the greeting bubbles and the back-to-top button;
- WCAG AA colour contrast, including every language colour pair.
When editing, keep one h1 per page, label every input, and keep alt text meaningful.
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=/school 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 enrolments, 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#
useSearchParams() should be wrapped in a suspense boundaryduring build. Any component that reads the query string must render inside<Suspense>, as the course list, course page, placement test, timetable, teachers and enrolment form do.- Images or links 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 course page shows "Spanish for Beginners" in "view source". The static HTML holds the first course; the
browser then shows the course from
?id=. For a page per course in the HTML, switch to a dynamic route/courses/[id]withgenerateStaticParams(). - A new course doesn't appear in a language's colour. Its
langmust match anidinLANGS, and that language'stoneneeds a.tone-xxrule inglobals.css. - The enrolment form or placement test starts with old answers. Clear the saved data (see Saved data) or use "Start again".
pnpm lintcrashes withcontextOrFilename.getFilename is not a function. Keepsettings.react.versionineslint.config.mjs.
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 artwork and teacher portraits. Lingua, its teachers and its students are fictional.