Freeframe

Documentation

Academy

v1.0.1Updated Nov 11, 2026A friendly online course platform with a catalog, lesson player and student dashboard.

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.

sh
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.yaml

Components, 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:

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

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

ts
{ 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 modules entry becomes a module (m1, m2, …); lesson ids are m<module>-l<lesson>, for example m2-l3.
  • Lesson titles come from lessons (one array per module) when present, otherwise from LESSON_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: FEATURED in app/pages/index.vue.
  • Plan prices are text in app/pages/pricing.vue ($15/$19 and $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.

  • Header and phone menu links: NAV and LEARN_LINK in app/data/nav.ts. Each has href, label and match (the routes that mark it as current with aria-current="page").
  • Footer columns: FOOTER_COLUMNS in the same file.
  • To add a page, create app/pages/faq.vue with a <main id="main"> root and a usePageSeo() call, add "/faq" to routes in nuxt.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) from themeScript.
  • Remove dark mode: remove the theme part of themeScript and the data-theme-toggle button.

Features#

Course catalog#

app/pages/courses.vue.

  • Search matches title, summary, topic name and instructor name.
  • Filters: topics and levels (any number), length (short under 5 h, medium 5–10 h, long over 10 h), price (free, paid), minimum rating (4.8, 4.7).
  • Sort: popular (most students, the default), rating, newest (order in COURSES, 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=Beginner works as a link. Parameters: q, topic and level (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-live region.

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, from makeRef()); 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-progress class with the --p CSS 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 with aria-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) shows CourseCover as 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.

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.ts fades in .reveal elements as they scroll into view, and reveals everything above one too, so jumping down the page never leaves content hidden. Don't put reveal on an element with a :class binding: Vue would remove the is-visible class it adds.
  • CountUp animates the home page stats once visible.
  • toast("…") from app/utils/toast.ts shows 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:

ts
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-view is 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, a summary_large_image Twitter card and a canonical link.
  • Canonical, og:url and og:image URLs are absolute: SITE_URL plus the base path. Set your domain when you build: SITE_URL=https://your-domain.com pnpm generate (the fallback is https://northfield.example in nuxt.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.txt and a sitemap (for example the @nuxtjs/sitemap module) if you need them.

Accessibility#

Built in:

  • Skip link to <main id="main">, landmarks, and one h1 per page.
  • Visible focus outlines and AA contrast in light and dark mode.
  • Phone menu with aria-expanded that closes with Escape; current page marked with aria-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#

sh
pnpm generate   # static site in .output/public/
pnpm preview    # preview it locally

Every 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 /courses from courses.html, set autoSubfolderIndex: true in nitro.prerender in nuxt.config.ts.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/your/path pnpm generate; it becomes app.baseURL, which every NuxtLink, 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 course id exactly.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) first; it runs nuxt prepare.
  • A hydration warning after editing a page. Read window, Date and localStorage only in onMounted() 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, use NuxtLink for internal links and write the base path out for files in public/.
  • Shared links show the wrong domain. Set SITE_URL when 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.

Search documentation

Search the guides and every template's documentation