Freeframe

Documentation

Lingua

v1.0.0Updated Nov 12, 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 Villita Lane in San Antonio 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 schedule, a teacher directory, fees in tabs and a four-step enrollment form with a live fee summary. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.

Live demo: freeframe.dev/templates/lingua

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/ (every page prerendered, plus 404.html)
pnpm preview Serve the last build locally
pnpm build A Node.js server build in .output/ (pages are still prerendered)
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm build:demo Build for /demos/nuxt/lingua into demo/ (the freeframe.dev demo; you can delete it)

pnpm install runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.

Pages#

Route File Notable sections and features
/ app/pages/index.vue 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 app/pages/courses.vue Every course with filters by language, level and format (kept in the URL), and the CEFR levels explained
/course?id=<id> app/pages/course.vue One course: facts, tabs (overview, syllabus, teacher), fee card with seats left, related courses
/placement app/pages/placement.vue Ten-question placement test with a level and recommended courses
/teachers app/pages/teachers.vue Eight teachers with a language filter and the courses each teaches
/timetable app/pages/timetable.vue A week of classes (Monday to Saturday) with filters by language, format and time of day
/enrol app/pages/enrol.vue Fees in tabs (group, private, companies) and the four-step enrollment form (#enrol)
/about app/pages/about.vue Story and numbers, teaching method, the building, FAQ
/contact app/pages/contact.vue Contact form with topics, map, address, front desk hours, getting here
404 app/error.vue "Lost in translation" page with links home and to the courses (generated as 404.html)

Project structure#

nuxt.config.ts            base path, <head> defaults, SITE_URL, prerendered routes, Tailwind
app/
  app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see Troubleshooting)
  router.options.ts       writes query strings with encodeURIComponent, like the HTML version
  error.vue               404 page
  layouts/default.vue     skip link, header, page, footer, back-to-top button
  pages/                  one .vue file per page (see Pages)
  components/
    SiteHeader.vue        header, desktop navigation, mobile menu
    SiteFooter.vue        footer, newsletter form, copyright year
    CourseCard.vue        the course card used on every page that lists courses
    PlacementTest.vue     placement test (/placement)
    EnrolForm.vue         four-step enrollment with fee summary (/enrol)
    LineIcon.vue          line icons (paths in app/data/icons.ts)
    BackToTop.vue         back-to-top button
  composables/
    useTabs.ts            ARIA tabs (keyboard and #hash support)
    useDataForm.ts        contact and newsletter form validation
    useTermShift.ts       rolling term dates (useTermShift, useTermDate)
    usePageSeo.ts         per-page title, description, canonical and Open Graph tags
    useCurrentPage.ts     aria-current for the header and menu links
    useYear.ts            current year in the footer
    useAsset.ts           asset(path): prefixes public files with the base path
  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
    icons.ts              SVG paths of the line icons
  utils/
    school.ts             formatting, URL parameters, placement scoring, enrollment draft and fees
    term.ts               termShift(): the demo's rolling term
    safeStorage.ts        localStorage with an in-memory fallback, readJSON()
  plugins/
    reveal.client.ts      scroll reveals
    same-page.client.ts   a link to the page you're on reloads it at the top, like the HTML version
    keep-hash.client.ts   keeps the #hash exactly as typed (for the fee tabs)
    stable-build.server.ts  reproducible builds
  assets/css/main.css     Tailwind v4 import, fonts, @theme tokens and component classes
  assets/fonts/           Lexend and Young Serif (woff2) and their licenses
public/img/               logo, favicon, illustrations, teacher portraits (teachers/), social image

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.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 color 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 color comes from its tone in LANGS ("es" uses .tone-es); add a .tone-xx rule and --color-xx tokens for a new language. The browser theme color (#fbf8f1) is in app.head in nuxt.config.ts.

Typography#

Two fonts are used: Young Serif (headings, font-display) and Lexend (text, font-sans). Their Latin files are in app/assets/fonts/ (with their SIL Open Font License texts) and declared with @font-face at the top of main.css, 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 app/assets/fonts/, change the @font-face rule and the --font-sans or --font-display token.

Content#

Almost everything the pages list comes from app/data/school.ts:

Export What it holds
LANGS id, two-letter code, name, greeting (hello), color 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 (see Demo dates below)
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 (dollars) and the 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: 395,
  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 dollars; seats is the seats left ("Only 3 seats left" shows in the accent color from 3 down). Add an object to COURSES and the course appears in the course list, the schedule, the enrollment form, its teacher's card, the home page's language card counts 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 (January 11 to March 26, 2027). While KEEP_DEMO_CURRENT is true and that term has started (in Central time), the term and every course's starts move forward to the same term the next year, by whole weeks so weekdays never change (January 11, 2027 becomes January 10, 2028), so visitors never see a course that has already begun. termShift() in app/utils/term.ts works out the shift; useTermShift() and useTermDate() in app/composables/useTermShift.ts apply it once the page has loaded, so the generated HTML 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), greeting bubble positions (BUBBLES) and teachers (TEAM) at the top of app/pages/index.vue, with the student quotes in its template; the price cards in app/data/fees.ts; the text of the about and contact pages in their .vue files.

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). Reference files in public/ with asset() from useAsset(), which adds the base path. The logo is public/img/logo.svg and the favicon public/img/favicon.svg. The social image is public/img/og-image.png (1200×630); its source is public/img/og-image.svg.

Header links are NAV in app/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 & enrollment to the mobile menu. The footer's link columns are the LEARN and SCHOOL_LINKS arrays in app/components/SiteFooter.vue; the phone and email are in SCHOOL in nav.ts. To add a page, create app/pages/<name>.vue (its root element is <main id="main" tabindex="-1" class="flex-1 focus:outline-none">, and it calls usePageSeo()), add its route to routes in nuxt.config.ts and link it from NAV.

Features#

Course filters#

app/pages/courses.vue filters COURSES by language, level and format. The choices are written to the URL (/courses?lang=french&format=online) with history.replaceState (setQuery() in app/utils/school.ts), 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 (app/pages/course.vue). It reads id (any case) once the page has loaded; an unknown or missing id shows the first course, which is also what the generated HTML contains. The page title follows the course, and the "Enroll in this course" link opens /enrol?course=<id>#enrol. Following a link to another course opens it from the top.

Tabs#

The course page (overview, syllabus, teacher) and the fees (group, private, companies) use useTabs() from app/composables/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#

app/components/PlacementTest.vue 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 app/utils/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.

Schedule#

app/pages/timetable.vue 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. The term dates in the introduction follow the demo dates.

Teacher filter#

app/pages/teachers.vue shows the teachers who teach the chosen language (toggle buttons with aria-pressed), with a live count; /teachers?lang=spanish selects a language. Each card lists the courses that teacher runs this term, from COURSES.

Enrollment#

app/components/EnrolForm.vue is a four-step form: course, your details, options (payment plan, discount, 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% discount, course book, total, and with installments three monthly payments that add up to the penny. /enrol?course=<id> (any case) chooses the course. The confirmation promises an email within one business day and shows a short reference (LI- and four characters, made in the browser by makeRef() in app/utils/school.ts; show your booking system's own reference instead if it returns one). It sends nothing until you connect it; see Forms.

Header, mobile menu and scroll reveals#

SiteHeader.vue adds a border after scrolling (is-scrolled), and its menu button toggles the full-screen mobile menu (aria-expanded; Esc closes it and returns focus, and it closes on navigation and when the window grows to desktop width). app/plugins/reveal.client.ts 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 nuxt.config.ts), content is visible straight away. The greeting bubbles on the home page bob gently (.bob), except with reduced motion.

Back to top#

BackToTop.vue, 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: the course fee card and the enrollment summary) or while the mobile menu is open. To remove it, delete the component, its line in app/layouts/default.vue and the .back-to-top styles in main.css.

Forms#

Form Where Validates
Placement test PlacementTest.vue An answer on every step
Enrollment EnrolForm.vue Course; first and last name, email, optional phone, experience; terms
Contact (data-form="contact") app/pages/contact.vue Name, email, message of at least 20 characters
Newsletter (data-form="newsletter") SiteFooter.vue Email

The contact and newsletter forms use useDataForm() from app/composables/useDataForm.ts: native constraints (required, type="email", minlength) are checked when a field loses focus and on submit, errors appear under each field (linked with aria-describedby), focus moves to the first error, and the element marked data-form-success is shown and focused. The contact form's success message shows a short reference ([data-ref]) and promises a reply within one business day; the newsletter one asks subscribers to look out for a confirmation email. Change them to match what really happens. /contact?topic=private (any case, only the form's own topics) chooses the topic. Nothing is sent until you connect the forms, although their success messages read as if it had been.

To post to a form service such as Formspree, replace the comment in submit() in useDataForm.ts and make the function async:

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

then keep the existing reset and success code. The enrollment form has the same comment in onSubmit() in EnrolForm.vue: send the draft (or new FormData(e.currentTarget as HTMLFormElement)) to your booking system or payment provider before showing the confirmation. If you deploy with pnpm build to a Node.js host, you can post to your own server route instead (server/api/enrol.post.ts with defineEventHandler). Always re-check the course, seats 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

Storage goes through safeStorage in app/utils/safeStorage.ts, which keeps values in memory when the browser blocks localStorage (a sandboxed frame, strict privacy settings) or it is full, so the page keeps working for the visit. It is only read after the page has loaded, never while the HTML is generated.

The enrollment draft is saved on every change and read with readDraft() in app/utils/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 enrollment. 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#

  • Each page calls usePageSeo({ title, description, page }) (app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph and Twitter tags, and <body data-page>. The 404 page passes noindex.
  • Canonical URLs and the social image URL are absolute: SITE_URL plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-school.com pnpm generate

Without it, URLs fall back to https://lingua.example (runtimeConfig.public.siteUrl in nuxt.config.ts). For launch, consider adding a sitemap and robots.txt (a static public/robots.txt works), and EducationalOrganization and Course structured data as JSON-LD with useHead().

Accessibility#

Built in:

  • a "Skip to content" link (in app/layouts/default.vue) and landmarks on every page;
  • visible focus styles (:focus-visible in main.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, enrollment step and result;
  • reduced motion respected by reveals, smooth scrolling, the greeting bubbles and the back-to-top button;
  • WCAG AA color contrast, including every language color pair.

When editing, keep one h1 per page, label every input, and keep alt text meaningful.

Building and deploying#

pnpm generate writes a fully static site to .output/public/: one .html file per page (courses.html, served at /courses), 404.html and the assets in _nuxt/. Deploy it anywhere:

  • Netlify / Cloudflare Pages / Vercel: build command pnpm generate, publish directory .output/public.
  • Any static host: upload the contents of .output/public/. Configure the host to serve courses.html for /courses (most do) and 404.html for unknown URLs.

To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/school pnpm generate. nuxt.config.ts passes it to app.baseURL; links get it automatically and public files through asset(). pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.

Troubleshooting#

  • The course page shows "Spanish for Beginners" in "view source". The generated 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 (app/pages/courses/[id].vue) and list every course in routes in nuxt.config.ts.
  • Images break when deployed to a sub-folder. Build with DEMO_BASE_PATH set, and reference public files with asset() (links made with <NuxtLink> get the base path automatically).
  • Social previews use lingua.example. Set SITE_URL when building.
  • A new course doesn't appear in a language's color. Its lang must match an id in LANGS, and that language's tone needs a .tone-xx rule in main.css.
  • A link to the page you're on, or to another course, seems to do nothing in development. Pages are keyed by their path and query (app/app.vue) and app/plugins/same-page.client.ts restarts the page at the top; keep both when you change the app shell.
  • The enrollment form or placement test starts with old answers. Clear the saved data (see Saved data) or use "Start again".
  • pnpm lint or pnpm typecheck can't find .nuxt/. Run pnpm install (or npx nuxt prepare) first.
  • Dates show the next year's term. That's the demo date rule; set KEEP_DEMO_CURRENT = false in app/data/school.ts.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Lexend and Young Serif are included under the SIL Open Font License. Lingua, its teachers and its students are fictional.

Search documentation

Search the guides and every template's documentation