Overview#
Academy is a complete website for an online school, built for the fictional Northfield Academy, which teaches design, code and data. It has a searchable course catalog, course pages with a curriculum, learning paths, instructor profiles, pricing with a team seats calculator, a learner dashboard and a lesson player, in light and dark themes. This is the Nuxt version, version 1.0.1: Nuxt 4, Vue 3 single-file components, TypeScript and Tailwind CSS v4, generated as a fully static site. It uses no UI libraries. See it live at freeframe.dev/templates/academy.
Quick start#
Requirements: Node.js 24 or newer and pnpm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm preview |
Serve the last build locally |
pnpm typecheck |
nuxt prepare, then type-check with vue-tsc |
pnpm lint |
ESLint (flat config from @nuxt/eslint) |
pnpm build:demo |
Build for /demos/nuxt/academy into demo/ (only used by freeframe.dev; you can delete it) |
pnpm install runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with course search, topic tiles with course counts, featured courses, how it works, learning paths, count-up stats, instructors, learner stories, plans teaser |
/courses |
app/pages/courses.vue |
Catalog: search, filters (topic, level, length, price, rating), six sort orders, grid/list view, filters kept in the URL, empty state |
/course?id=… |
app/pages/course.vue |
One course (for example /course?id=react-from-scratch): outcomes, curriculum accordion with free previews, preview dialog, instructor, reviews histogram, FAQs, sticky enroll card, related courses |
/paths |
app/pages/paths.vue |
Each learning path's courses in order, total hours, weeks and bundle price |
/instructors |
app/pages/instructors.vue |
Instructor grid and the "Teach with us" form (#teach) |
/instructor?id=… |
app/pages/instructor.vue |
One instructor (for example /instructor?id=hana): stats, bio, tags, their courses, other instructors |
/pricing |
app/pages/pricing.vue |
Monthly/yearly switch, team seats calculator and quote form (#teams), comparison table, FAQs (#faq) |
/learn |
app/pages/learn.vue |
Learner dashboard ("My learning"): stats, streak, continue learning, progress rings, certificates, recommendations, reset |
/lesson?course=…&lesson=… |
app/pages/lesson.vue |
Lesson player (for example /lesson?course=sql-essentials&lesson=m1-l1): course sidebar, mark as complete, Overview/Notes/Resources tabs, previous/next, locked lessons until you enroll |
| 404 | app/error.vue |
Course search and suggested courses (generated as 404.html) |
/course, /instructor and /lesson are one static page each. They read their ids from the URL once the page
has loaded (in onMounted()), so the generated HTML shows the first course, instructor or lesson, and an
unknown id falls back to it too. For one URL per course (/courses/design-systems), add a dynamic page such as
app/pages/courses/[id].vue and list every course route in routes in nuxt.config.ts.
Project structure#
academy/
├─ app/
│ ├─ app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see Features)
│ ├─ error.vue the 404 page
│ ├─ router.options.ts writes query strings like the HTML version (?topic=design,data)
│ ├─ layouts/default.vue skip link, header, page, footer, toast and back-to-top button
│ ├─ pages/ one .vue file per page (see above)
│ ├─ components/
│ │ ├─ SiteHeader.vue logo, menu, theme switch, "Join for free", phone menu
│ │ ├─ SiteFooter.vue footer links, newsletter, copyright year
│ │ ├─ NewsletterForm.vue footer sign-up form
│ │ ├─ SearchForm.vue course search (home and 404)
│ │ ├─ CourseCard.vue InstructorCard.vue PathCard.vue CourseGrid.vue
│ │ ├─ CourseCover.vue InstructorAvatar.vue StarRating.vue CertificateArt.vue generated SVG art
│ │ ├─ AppDialog.vue modal built on <dialog>
│ │ ├─ CountUp.vue animated numbers
│ │ ├─ FieldError.vue a field's error message
│ │ ├─ BackToTop.vue back-to-top button
│ │ └─ AcademyLogo.vue CheckIcon.vue ChevronIcon.vue PlayIcon.vue ArrowIcon.vue CloseIcon.vue SearchIcon.vue
│ ├─ composables/
│ │ ├─ usePageSeo.ts title, description, canonical and Open Graph tags
│ │ └─ useCurrentPage.ts aria-current for menu links
│ ├─ data/
│ │ ├─ catalog.ts topics, instructors, courses, paths and the curriculum helpers
│ │ └─ nav.ts header, phone menu and footer links
│ ├─ utils/
│ │ ├─ progress.ts saved progress, streak and notes (load, clean, save)
│ │ ├─ art.ts cover shapes and hair styles for the generated art
│ │ ├─ safeStorage.ts localStorage with an in-memory fallback
│ │ ├─ toast.ts toast()
│ │ └─ forms.ts EMAIL pattern, makeRef(), getParam()
│ ├─ plugins/
│ │ ├─ reveal.client.ts scroll reveals
│ │ ├─ same-page.client.ts links to the page already showing start it afresh
│ │ └─ stable-build.server.ts reproducible builds
│ └─ assets/
│ ├─ css/main.css Tailwind CSS v4, @font-face, @theme tokens and component classes
│ └─ fonts/ Plus Jakarta Sans and Caveat (Latin .woff2) with their OFL licenses
├─ public/img/ favicon.svg, og-image.png and its og-image.svg source
├─ nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├─ eslint.config.mjs, tsconfig.json
└─ package.json, pnpm-workspace.yaml, pnpm-lock.yamlComponents, composables, utils and Vue's APIs are auto-imported; data files are imported by path
(import { COURSES } from "~/data/catalog").
Customizing#
Brand and colors#
Design tokens live in the @theme block of app/assets/css/main.css:
| Token | Used for |
|---|---|
--color-leaf-50 … --color-leaf-950 |
Brand green: buttons, links, eyebrows, focus rings |
--color-sun-100 … --color-sun-500 |
Friendly yellow accent: badges, streak, highlights |
--color-ink |
Body text |
--color-paper |
Light background |
--color-night, --color-night-2, --color-night-3 |
Dark-mode backgrounds |
Change the leaf scale to rebrand (keep the 50–950 steps); the dev server picks it up immediately:
@theme {
--color-leaf-600: #2563eb;
--color-leaf-700: #1d4ed8;
--color-leaf-800: #1e40af;
}Not tokens: the logo (AcademyLogo.vue) and the certificate (CertificateArt.vue) use the brand green and yellow
directly, topic art colors are TOPICS in app/data/catalog.ts, and topic label text colors are TOPIC_TEXT
beside it. The theme-color meta tags in nuxt.config.ts set the browser UI color.
Typography#
Plus Jakarta Sans (all text) and Caveat (the handwritten accents, class hand) are self-hosted from
app/assets/fonts/, declared with @font-face at the top of main.css. They are the same Latin files Google
Fonts serves the HTML version, so text sets exactly like it. The @theme block maps them:
--font-sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-hand: "Caveat", "Segoe Print", "Bradley Hand", cursive;To swap a font, add its .woff2 (and license) to app/assets/fonts/, change the @font-face rule and the
token. The certificate and 404 artwork name the families in SVG font-family attributes; update those too.
Content#
Static text (hero copy, FAQs, learner stories, plans) is markup in app/pages/. Everything about courses
comes from app/data/catalog.ts, fully typed:
| Export | Type | Contents |
|---|---|---|
TOPICS |
Record<TopicId, Topic> |
The five topics with name and art colors |
INSTRUCTORS |
Instructor[] |
id, name, role, topic, portrait colors (skin, hair, style), rating, students, bio, tags |
COURSES |
Course[] |
id, title, topic, level, hours, price (0 = free), rating, reviews, students, instructor, optional badge, updated, summary, outcomes, modules, optional lessons |
PATHS |
LearningPath[] |
id, name, topic, weeks, blurb, courses (course ids in order) |
Add a course:
{ id: "figma-basics", title: "Figma for Beginners", topic: "design", level: "Beginner", hours: 4, price: 39,
rating: 4.7, reviews: 120, students: 2400, instructor: "amara", badge: "New", updated: "Oct 2026",
summary: "Learn frames, auto layout and components by designing a small app.",
outcomes: ["Use frames and auto layout", "Build reusable components"],
modules: ["Getting started", "Auto layout", "Components", "Prototyping"] },TypeScript checks the shape, and the course appears in the catalog, on /course?id=figma-basics, on its
instructor's page and in any path that lists it. To add a topic, add it to the TopicId type, TOPICS and
TOPIC_TEXT, and add cover motifs in app/utils/art.ts.
How curriculum(course) builds modules and lessons:
- Each
modulesentry becomes a module (m1,m2, …); lesson ids arem<module>-l<lesson>, for examplem2-l3. - Lesson titles come from
lessons(one array per module) when present, otherwise fromLESSON_PATTERN. - Lesson lengths are generated from the course id (so they never change) and add up to
hours. - The first two lessons of the first module are free previews.
Saved progress refers to these ids, so renaming or reordering modules on a live site changes which lessons a visitor's progress points to.
Other content:
- Featured courses on the home page:
FEATUREDinapp/pages/index.vue. - Plan prices are text in
app/pages/pricing.vue($15/$19and$24/$29). The team calculator's per-seat prices are separate (const base = annual.value ? 24 : 29;in the same file), so change both together. - The catalog heading ("Twelve courses …") and the home page stats are plain text; update them when your catalog changes.
Images and artwork#
Course covers, portraits, stars and certificates are SVG drawn by components:
| Component | Draws | Notes |
|---|---|---|
CourseCover |
Cover art (320×200 viewBox, used at 16:10) | Shapes from coverShapes() in app/utils/art.ts; two alternating motifs per topic. Pass label for a meaningful image |
InstructorAvatar |
Instructor portrait (square, circular crop) | Uses skin, hair and style (0–5, HAIR in art.ts) |
StarRating |
Star rating with partial fill | Text alternative "Rated 4.8 out of 5" |
CertificateArt |
Certificate of completion (560×380) | Used on /learn |
To use photos, put them in public/img/ and render an <img> inside these components, for example
<img :src="${$config.app.baseURL}img/covers/${course.id}.jpg" alt=""> (keep 16:10, such as 1280×800, for
covers; square, at least 320×320, for portraits). Files in public/ need the base path written out like this.
The dashboard learner, Alex Morgan, is STUDENT in app/pages/learn.vue (the welcome line is text in the
same file). Replace it with the signed-in user's name when you add real accounts.
Social image: public/img/og-image.png (1200×630), exported from og-image.svg. The favicon is
public/img/favicon.svg.
Navigation, header and footer#
- Header and phone menu links:
NAVandLEARN_LINKinapp/data/nav.ts. Each hashref,labelandmatch(the routes that mark it as current witharia-current="page"). - Footer columns:
FOOTER_COLUMNSin the same file. - To add a page, create
app/pages/faq.vuewith a<main id="main">root and ausePageSeo()call, add"/faq"toroutesinnuxt.config.ts, and link to it with<NuxtLink to="/faq">.
Dark mode#
A small script in app.head (themeScript in nuxt.config.ts) applies the saved theme before the page paints,
so there's no flash. It reads localStorage key theme ("dark" or "light") and otherwise follows the system
setting. The moon/sun button in SiteHeader.vue switches the dark class and saves the choice.
- Default to light: remove
||(!t&&window.matchMedia('(prefers-color-scheme: dark)').matches)fromthemeScript. - Remove dark mode: remove the theme part of
themeScriptand thedata-theme-togglebutton.
Features#
Course catalog#
app/pages/courses.vue.
- Search matches title, summary, topic name and instructor name.
- Filters: topics and levels (any number), length (
shortunder 5 h,medium5–10 h,longover 10 h), price (free,paid), minimum rating (4.8,4.7). - Sort:
popular(most students, the default),rating,newest(order inCOURSES, last first),length,price-asc,price-desc. - The filters are read from the URL after loading (unknown values are ignored) and written back with
history.replaceState(writeUrl()), so/courses?topic=data&level=Beginnerworks as a link. Parameters:q,topicandlevel(comma-separated),length,price,rating,sort. - The grid/list choice is saved in
localStorage(academy-view). - On small screens the filters are behind a "Filters" button with
aria-expanded. - The result count is an
aria-liveregion.
Course page#
app/pages/course.vue.
- The curriculum uses native
<details>elements; "Expand all" opens or closes every module. - Preview lessons link to the player, and the "Preview this course" dialog (
AppDialog) lists them. Escape and clicking the backdrop close it. - The reviews histogram matches the course rating.
- Enroll (
data-enrol) saves the enrollment in the browser and shows a toast; no payment is taken. For a paid course the toast gives an order reference (NA-and four characters, frommakeRef()); when you connect a checkout, show its order number instead. Once enrolled, the button continues to your next lesson. - The enroll card is sticky on large screens and marked
data-back-to-top-hide. - The tab title and meta description follow the course (getters passed to
usePageSeo()).
Learning paths#
app/pages/paths.vue renders each entry in PATHS as a section with an anchor (/paths#data-analyst). Hours
and the bundle price are added up from the courses; weeks is your own estimate.
Learner dashboard#
app/pages/learn.vue.
- Stats, the learning streak and the week strip (filled in after loading, since it depends on today's date) come from saved progress.
- "Continue learning" picks the first course still in progress, at its last opened lesson.
- Progress rings use the
.ring-progressclass with the--pCSS variable. - Finished courses show a certificate, opened in a dialog by "View certificate".
- "Reset demo progress" clears saved progress and notes and reloads the page.
Until the visitor acts, the dashboard shows sample progress (DEMO in app/utils/progress.ts) with a note.
Their first enrollment or completed lesson replaces the sample with their own (makeOwn()).
Lesson player#
app/pages/lesson.vue.
- The sidebar lists modules and lessons with check marks; the current lesson has
aria-current="page". On small screens it's a drawer behind the "Course contents" button. - Mark as complete (
data-complete) toggles the lesson, counts today in the streak (bumpStreak()) and moves "last lesson" to the next unfinished one. Its state is exposed witharia-pressed. - Lessons other than previews are locked until the visitor enrolls (or completes a preview lesson).
- Tabs (
TABS: Overview, Notes, Resources) follow the ARIA tabs pattern with arrow keys, Home and End. - Notes save a moment after you stop typing (
saveNote()), per lesson. - The video area (
data-player) showsCourseCoveras a placeholder. Replace it with your player, for example an<iframe>or<video>.
Pricing and team seats calculator#
app/pages/pricing.vue. The billing switch (role="switch") swaps the plan prices and notes. The seats slider
(#seats, 2–200) and number field (#seats-number, 2–500) stay in sync; values outside 2–500 show an error and
are corrected when the field loses focus. Per-seat price is $24 (yearly) or $29 (monthly), less 5% from 10 seats,
10% from 50 and 15% from 200.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, so
app/plugins/same-page.client.ts and the page key in app/app.vue make them behave the same: the next lesson,
another course or a link to the page already showing (the Courses link on a filtered catalog) starts the page
afresh at the top.
Reveals, counters and toasts#
app/plugins/reveal.client.tsfades in.revealelements as they scroll into view, and reveals everything above one too, so jumping down the page never leaves content hidden. Don't putrevealon an element with a:classbinding: Vue would remove theis-visibleclass it adds.CountUpanimates the home page stats once visible.toast("…")fromapp/utils/toast.tsshows a short status message (role="status").- Reduced motion shows everything at once.
Back-to-top button#
app/components/BackToTop.vue, mounted in the layout. It appears after about one screen of scrolling, scrolls
back up (instantly with reduced motion), moves focus to <main id="main">, lifts itself above toasts and hides
while it would cover an element marked data-back-to-top-hide. Mark anything pinned to the bottom of the screen
with data-back-to-top-avoid. To remove it, delete the component, its line in the layout and the .back-to-top
styles in main.css.
Forms#
| Form | Where | Validates |
|---|---|---|
| Newsletter | NewsletterForm.vue (footer) |
Email present and well-formed |
| Course search | SearchForm.vue (home, 404) |
An empty search opens the full catalog |
| Teach with us | onSubmit in app/pages/instructors.vue |
Name, email, topic chosen, idea at least 20 characters |
| Team quote | onSubmit in app/pages/pricing.vue |
Name, work email, company or school |
Errors show under each field (FieldError, with aria-invalid and aria-describedby on the field) and focus
moves to the first problem. The teach and team quote confirmations include a short reference (NA- and four
characters from makeRef(); use your form service's own reference if it returns one) and say what happens next.
Change these to match how you actually handle sign-ups and requests. Nothing is sent until you connect the
forms.
To connect one, post the data in its onSubmit before showing the success message. For example, with
Formspree in app/pages/pricing.vue:
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
errors.name = "Sorry, something went wrong. Please try again.";
return;
}
reference.value = makeRef();
sent.value = true;To run your own server code instead, add a server route such as server/api/quote.post.ts
(export default defineEventHandler(async (event) => { const data = await readBody(event); … })), deploy with
pnpm build to a Node.js host, and fetch("/api/quote", …) from the form.
Enrollment and progress are front-end only. To support real accounts, replace the functions in
app/utils/progress.ts (loadProgress, saveProgress, saveNote, resetProgress) with calls to your API.
Saved data (localStorage)#
| Key | Shape | Written by |
|---|---|---|
academy-progress |
{ enrolled: { "<course id>": { done: ["m1-l1", …], last: "m1-l2" } }, streak: { count: 3, last: "2026-10-07" } } |
Enroll, mark as complete, opening a lesson |
academy-notes |
{ "<course id>/<lesson id>": "note text" } (at most 5,000 characters each) |
Lesson notes |
academy-view |
"grid" or "list" |
Catalog view buttons |
theme |
"dark" or "light" |
Theme switch |
Everything is read after the page has loaded and validated by cleanProgress() and loadNotes():
- Unknown courses and lesson ids, duplicates and wrong types are dropped.
- The streak count must be a whole number from 0 to 3,650 (anything else resets the streak) and dates must look
like
YYYY-MM-DD. - The cleaned value is written back if anything changed; unreadable JSON is removed.
academy-viewis only used when it is exactly"list".
safeStorage (app/utils/safeStorage.ts) falls back to memory when storage is blocked or full, so the site keeps
working for that page view. Reset with Reset demo progress on /learn, or localStorage.clear() in the
browser console.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, Open Graph tags, asummary_large_imageTwitter card and a canonical link. - Canonical,
og:urlandog:imageURLs are absolute:SITE_URLplus the base path. Set your domain when you build:SITE_URL=https://your-domain.com pnpm generate(the fallback ishttps://northfield.exampleinnuxt.config.ts). - The course, instructor and lesson pages are one page each, so the generated HTML has the first course's title. For per-course SEO, use dynamic routes (see Pages).
- The 404 page passes
noindex. - Add a
public/robots.txtand a sitemap (for example the@nuxtjs/sitemapmodule) if you need them.
Accessibility#
Built in:
- Skip link to
<main id="main">, landmarks, and oneh1per page. - Visible focus outlines and AA contrast in light and dark mode.
- Phone menu with
aria-expandedthat closes with Escape; current page marked witharia-current="page". - ARIA tabs, modal dialogs, a switch for billing, progress bars and live regions for counts and toasts.
- Text alternatives for star ratings, progress rings and covers; decorative SVGs are
aria-hidden. - Reduced motion turns off reveals, count-ups and smooth scrolling.
When editing, keep one h1 per page, label every input, and check new colors for contrast in both themes.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # preview it locallyEvery page is prerendered to its own file (courses.html, not courses/index.html) and linked as /courses.
- Netlify or Cloudflare Pages: build command
pnpm generate, publish directory.output/public. - Vercel: import the repository; Nuxt is detected automatically.
- Any static host: upload
.output/public/. If the host doesn't serve/coursesfromcourses.html, setautoSubfolderIndex: trueinnitro.prerenderinnuxt.config.ts. - Sub-folder hosting: build with
DEMO_BASE_PATH=/your/path pnpm generate; it becomesapp.baseURL, which everyNuxtLink, script and stylesheet uses.
demo/ is the build used for the freeframe.dev demo; you can delete it.
TypeScript setup: typescript is an alias of @typescript/typescript6, the TypeScript 6 API that vue-tsc and
typescript-eslint need.
Troubleshooting#
- The dashboard shows courses I never took. That's the sample progress; enroll or complete a lesson, or press Reset demo progress.
- A course page shows the first course. The
?id=must match a courseidexactly. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) first; it runsnuxt prepare.- A hydration warning after editing a page. Read
window,DateandlocalStorageonly inonMounted()or event handlers, as the pages do, so the generated HTML and the first render in the browser match. - Links break when hosted in a sub-folder. Build with
DEMO_BASE_PATH, useNuxtLinkfor internal links and write the base path out for files inpublic/. - Shared links show the wrong domain. Set
SITE_URLwhen building. - Progress disappeared after renaming modules. Lesson ids come from module and lesson positions; renamed or reordered modules change what saved ids point to, and unknown ids are dropped.
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. Plus Jakarta Sans
and Caveat are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Northfield Academy, its
instructors, learners and companies are fictional.