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).
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 imageCustomizing#
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:
@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:
{ 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.
Navigation, header and footer#
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 |
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:
// 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 herethen 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 passesnoindex. - Canonical URLs and the social image URL are absolute:
SITE_URLplus the base path. Set it when you build:
SITE_URL=https://www.your-school.com pnpm generateWithout 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-visibleinmain.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, 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 servecourses.htmlfor/courses(most do) and404.htmlfor 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 inroutesinnuxt.config.ts. - Images break when deployed to a sub-folder. Build with
DEMO_BASE_PATHset, and reference public files withasset()(links made with<NuxtLink>get the base path automatically). - Social previews use
lingua.example. SetSITE_URLwhen building. - A new course doesn't appear in a language's color. Its
langmust match anidinLANGS, and that language'stoneneeds a.tone-xxrule inmain.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) andapp/plugins/same-page.client.tsrestarts 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 lintorpnpm typecheckcan't find.nuxt/. Runpnpm install(ornpx nuxt prepare) first.- Dates show the next year's term. That's the demo date rule; set
KEEP_DEMO_CURRENT = falseinapp/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.