Freeframe

Documentation

Lingua

v1.0.2Updated Oct 17, 2026A bright, friendly website for a language school with in-person and online classes.

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.

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/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 image

Customising#

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:

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

ts
{ 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.

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 Email

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():

tsx
// 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 metadata in src/app/layout.tsx.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which also sets the Open Graph image asset("/img/og-image.png"). The 404 page also asks search engines not to index it.
  • 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-school.com pnpm build

For 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-visible in globals.css) and one h1 per page;
  • form errors linked with aria-describedby/aria-invalid, with focus moved to the first error;
  • ARIA tabs, aria-current for steps and navigation, aria-pressed filter 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 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=/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 boundary during 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_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 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] with generateStaticParams().
  • A new course doesn't appear in a language's colour. Its lang must match an id in LANGS, and that language's tone needs a .tone-xx rule in globals.css.
  • The enrolment form or placement test starts with old answers. Clear the saved data (see Saved data) or use "Start again".
  • pnpm lint crashes with contextOrFilename.getFilename is not a function. Keep settings.react.version in eslint.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.

Search documentation

Search the guides and every template's documentation