Freeframe

Documentation

Academy

v1.0.6Updated Oct 17, 2026A friendly online course platform with a catalogue, 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 catalogue, course pages with a curriculum, learning paths, instructor profiles, pricing with a team seats calculator, a student dashboard and a lesson player. This is the Next.js version, 1.0.2: Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a fully static site. It uses no UI libraries.

Live demo: freeframe.dev/templates/academy

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server with hot reload
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
pnpm build:demo Build for /demos/nextjs/academy into demo/ (only used by freeframe.dev; you can delete it)

Pages#

Route File Purpose and notable features
/ src/app/page.tsx Home: hero with search, topic tiles, featured courses, how it works, learning paths, count-up stats, instructors, testimonials, pricing teaser
/courses src/app/courses/page.tsx → components/catalogue.tsx Catalogue: search, filters (topic, level, length, price, rating), six sort orders, grid/list view, filters kept in the URL
/course?id=… src/app/course/page.tsx → components/course-page.tsx One course: outcomes, curriculum accordion with free previews, preview dialog, instructor, reviews histogram, FAQs, sticky enrol card
/paths src/app/paths/page.tsx Learning paths with their courses, hours, weeks and bundle price
/instructors src/app/instructors/page.tsx Instructor grid and the "teach with us" form (components/teach-form.tsx)
/instructor?id=… src/app/instructor/page.tsx → components/instructor-page.tsx Instructor profile with stats, bio and courses
/pricing src/app/pricing/page.tsx → components/pricing.tsx Plans with a monthly/yearly toggle, team seats calculator, quote form, comparison table, FAQs
/learn src/app/learn/page.tsx → components/learn.tsx Student dashboard: stats, streak, continue learning, progress rings, certificates, recommendations
/lesson?course=…&lesson=… src/app/lesson/page.tsx → components/lesson-page.tsx Lesson player: course sidebar, mark complete, Overview/Notes/Resources tabs, previous/next
404 src/app/not-found.tsx Not-found page with search and suggested courses

Course, instructor and lesson pages read their ids from the URL in the browser (useSearchParams() inside a <Suspense> boundary), so one static page serves every course. During the static build and before hydration they show the first course. An unknown id also falls back to the first course or instructor.

If you'd rather have one URL per course (/courses/design-systems), move the course page to a dynamic route (src/app/courses/[id]/page.tsx) and export generateStaticParams() returning every id in COURSES.

Project structure#

src/
├── app/
│   ├── layout.tsx          root layout: fonts, metadata, theme boot script, header, footer, toasts, back to top
│   ├── globals.css         Tailwind CSS v4 import, @theme tokens and component classes
│   ├── page.tsx            home page
│   ├── courses/ course/ paths/ instructors/ instructor/ pricing/ learn/ lesson/   one folder per route
│   ├── not-found.tsx       404 page
│   └── icon.svg            favicon
├── components/
│   ├── site.tsx            header (NAV, mobile menu, theme toggle) and footer (newsletter)
│   ├── art.tsx             SVG art: CourseCover, Avatar, Stars, Certificate
│   ├── cards.tsx           CourseCard, InstructorCard, PathCard, CourseGrid
│   ├── catalogue.tsx       catalogue filters, sorting and view
│   ├── course-page.tsx  instructor-page.tsx  pricing.tsx  learn.tsx  lesson-page.tsx  teach-form.tsx
│   ├── dialog.tsx          accessible modal built on <dialog>
│   ├── toast.tsx           ToastProvider and useToast()
│   ├── reveal.tsx          RevealObserver for .reveal elements
│   ├── count-up.tsx        animated numbers
│   ├── search-form.tsx     course search (next/form)
│   ├── back-to-top.tsx     back-to-top button
│   └── icons.tsx           small SVG icons and the logo
├── fonts/                  Plus Jakarta Sans and Caveat (Latin .woff2) with their OFL licences
├── data/catalog.ts         topics, instructors, courses, paths and the curriculum generator
└── lib/
    ├── progress.ts         progress store (enrolments, completions, streak, notes)
    ├── forms.ts            EMAIL pattern and form helpers
    ├── metadata.ts         pageMetadata() for per-page titles and Open Graph
    └── use-title.ts        useTitle() for pages whose title comes from the URL
public/og-image.png         1200×630 social sharing image
next.config.ts              static export and optional base path
eslint.config.mjs  tsconfig.json  postcss.config.mjs  package.json

Pages are Server Components; only the interactive parts are Client Components (marked "use client").

Customising#

Brand and colours#

Design tokens live in the @theme block at the top of src/app/globals.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 (Logo in src/components/icons.tsx) and the certificate (Certificate in src/components/art.tsx) use the brand green and yellow directly, topic art colours are TOPICS in src/data/catalog.ts, and topic label text colours are TOPIC_TEXT beside it. The themeColor values in src/app/layout.tsx set the browser UI colour.

Typography#

Fonts are self-hosted with next/font/local in src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML version, so text sets exactly like it. Their SIL Open Font License texts are the *-OFL.txt files beside them.

tsx
const jakarta = localFont({
  src: "../fonts/PlusJakartaSans-latin.woff2",
  variable: "--font-jakarta",
  weight: "400 800",
  display: "swap",
  adjustFontFallback: false,
  fallback: ["Plus Jakarta Sans"],
  declarations: [{ prop: "font-family", value: "Plus Jakarta Sans" }],
});
const caveat = localFont({
  src: "../fonts/Caveat-latin.woff2",
  variable: "--font-caveat",
  weight: "700",
  display: "swap",
  adjustFontFallback: false,
  fallback: ["Caveat"],
  declarations: [{ prop: "font-family", value: "Caveat" }],
});

The font-family declaration keeps each face's real name, which the SVG certificate and 404 art use; fallback puts that name in the CSS variable, and adjustFontFallback: false leaves out the Arial-based fallback face, which would otherwise be picked instead of the font on systems that have Arial.

globals.css maps them to --font-sans (all text) and --font-hand (handwritten accents, class hand). To swap a font, add its .woff2 (and licence) to src/fonts/ and point src at it, or import another family from next/font/google; keep the same variable name, and update the fallback names in globals.css.

Content#

All course data is in src/data/catalog.ts, fully typed:

Export Type Contents
TOPICS Record<TopicId, Topic> The five topics with name and art colours
INSTRUCTORS Instructor[] id, name, role, topic, portrait colours (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 catalogue, 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 a cover motif in art.tsx.

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 at the top of src/app/page.tsx.
  • Pricing plans, comparison table and FAQs: PLANS, COMPARE and FAQ in src/components/pricing.tsx. The team calculator's per-seat prices are separate (const base = annual ? 24 : 29; in the same file), so change both together.
  • Course page FAQs and sample reviews: FAQ and REVIEWS in src/components/course-page.tsx.
  • The catalogue heading ("Twelve courses …") is text in catalogue.tsx; update it when your catalogue changes.

To load data from a CMS or API instead, replace the exports in catalog.ts with data fetched at build time; keep the same types so every component keeps working.

Images and artwork#

Course covers, portraits, stars and certificates are SVG React components in src/components/art.tsx:

Component Draws Notes
CourseCover Cover art (16:10) Colours from the course topic; two alternating motifs per topic
Avatar Instructor portrait (square, circular crop) Uses skin, hair and style (0–5)
Stars Star rating with partial fill Text alternative "Rated 4.8 out of 5"
Certificate Certificate of completion Used on /learn

To use photos, render an <img> (or next/image) inside these components: 16:10 for covers (for example 1280×800), square for portraits (at least 320×320). The static export sets images: { unoptimized: true }, so next/image works without an image server.

The dashboard student, Alex Morgan, is the STUDENT constant in src/components/learn.tsx (it also appears in the welcome line). Replace it with the signed-in user's name when you add real accounts.

Social image: public/og-image.png (1200×630). The favicon is src/app/icon.svg.

  • Header links: the NAV array at the top of src/components/site.tsx. Each item has href, label and match (the paths that mark it as current with aria-current="page").
  • Footer columns: the FooterColumn items in SiteFooter, in the same file.
  • To add a page, create src/app/<route>/page.tsx, export metadata = pageMetadata("Title", "Description"), and add it to NAV and/or the footer.

Dark mode#

src/app/layout.tsx injects a tiny boot script that applies the saved theme before the first paint, so there's no flash. It reads localStorage key theme ("dark" or "light") and otherwise follows the system setting. ThemeToggle in site.tsx switches the dark class and saves the choice.

  • Default to light: remove the ||(!t&&matchMedia('(prefers-color-scheme: dark)').matches) part of bootScript.
  • Remove dark mode: delete the boot script and the <ThemeToggle /> in the header.

Features#

Course catalogue#

src/components/catalogue.tsx.

  • Search matches title, summary, topic name and instructor name.
  • Filters: topics and levels (multiple), length (short under 5 h, medium 5–10 h, long over 10 h), price (free, paid), minimum rating (4.8, 4.7).
  • Sort: most popular (students), highest rated, newest (order in COURSES, last first), shortest, price low to high, price high to low.
  • The filters live in the URL (history.replaceState), so /courses?topic=data&level=Beginner works as a link. fromParams() and toQuery() translate between the URL and the Filters state.
  • The grid/list choice is saved in localStorage (academy-view) and restored after hydration.
  • The result count is announced politely to screen readers.

Course page#

src/components/course-page.tsx.

  • The curriculum uses native <details> elements; "Expand all" opens or closes every module.
  • Preview lessons link to the player, and the preview dialog (components/dialog.tsx) lists them.
  • Enrol calls enrol(course) from src/lib/progress.ts and shows a toast. No payment is taken. Once enrolled, the button continues to the next lesson.
  • The enrol card is sticky on large screens and marked data-back-to-top-hide.
  • The tab title is set with useTitle() (ending in SITE from src/lib/metadata.ts), because a static export can't know the course at build time.

Learning paths#

src/app/paths/page.tsx renders each entry in PATHS as a section with an anchor (/paths#data-analyst). Hours and bundle price are summed from the courses.

Student dashboard#

src/components/learn.tsx.

  • Stats, the learning streak and the week strip are calculated from saved progress.
  • "Continue learning" picks the first course still in progress.
  • Progress rings use the .ring-progress class with the --p CSS variable.
  • Finished courses show a certificate in a dialog.
  • "Reset demo progress" calls resetProgress().

Until the visitor acts, the dashboard shows sample progress (DEMO in progress.ts) with a note. Their first enrolment or completed lesson replaces the sample with their own progress.

Lesson player#

src/components/lesson-page.tsx.

  • The sidebar lists modules and lessons with completion ticks; the current lesson has aria-current="page". On small screens it's a drawer behind the "Course contents" button.
  • Mark as complete calls toggleComplete(), which updates the streak and the "last lesson". The button exposes its state with aria-pressed.
  • Non-preview lessons are locked until the visitor enrols or completes a preview lesson.
  • Tabs (TABS: Overview, Notes, Resources) follow the ARIA tabs pattern with arrow keys, Home and End.
  • Notes save as you type through saveNote().
  • The video area (data-player) shows CourseCover as a placeholder. Replace it with your player, for example an <iframe> or <video> element.

Pricing and team seats calculator#

src/components/pricing.tsx. The billing switch (role="switch") changes the plan prices; the seats slider (2–200) and number input (2–500) stay in sync, and out-of-range values are corrected on blur (clampSeats). Per-seat price is $24 (yearly) or $29 (monthly), less 5% from 10 seats, 10% from 50 and 15% from 200.

Reveals, counters and toasts#

  • RevealObserver (components/reveal.tsx) fades in elements with the reveal class and reveals everything above an element too, so jumping down the page never leaves content hidden.
  • CountUp animates numbers once visible.
  • useToast() from components/toast.tsx shows a short status message (role="status").
  • All of them respect reduced motion.

Back-to-top button#

src/components/back-to-top.tsx, mounted once in layout.tsx. 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. Remove <BackToTop /> from the layout and the .back-to-top styles in globals.css to drop it.

Forms#

Form Component Validates
Newsletter Newsletter in site.tsx (footer) Email present and well-formed
Course search SearchForm (next/form, submits to /courses?q=…) –
Teach with us TeachForm in teach-form.tsx Name, email, topic, idea at least 20 characters
Team quote TeamForm in pricing.tsx Name, work email, company or school

Errors show under each field (fieldProps() wires aria-invalid and aria-describedby) and focusFirst() moves focus to the first problem. Nothing is sent: each form has a "Demo only" comment where you'd send the data.

Option 1: a form service (works with the static export). In TeamForm's submit function, replace the "Demo only" comment:

tsx
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify(values),
});
if (!res.ok) {
  setErrors({ name: "Sorry, something went wrong. Please try again." });
  return;
}
setSent(true);

Make submit an async function when you do this.

Option 2: your own backend. Remove output: "export" from next.config.ts (see Building and deploying), then add a Route Handler, for example src/app/api/team/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.json();
  // Validate again on the server, then save it or send an email.
  return Response.json({ ok: true });
}

and fetch("/api/team", …) from the form. Server Actions work the same way once static export is off.

Enrolment and progress are front-end only. To support real accounts, keep the function names in progress.ts (enrol, toggleComplete, rememberLesson, saveNote, resetProgress) and make them call 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" } } enrol, toggleComplete, rememberLesson
academy-notes { "<course id>/<lesson id>": "note text" } (at most 5,000 characters each) saveNote
academy-view "grid" or "list" Catalogue view buttons
theme "dark" or "light" Theme toggle

src/lib/progress.ts keeps progress in a tiny external store read with useSyncExternalStore, so every component sees the same data. It listens to the storage event, so other open tabs update too. Everything read back is 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.

On the server useProgress() returns null, so components render a neutral state until hydration. Reset with Reset demo progress on /learn, or localStorage.clear() in the browser console.

SEO and sharing#

  • Each route exports metadata built with pageMetadata(title, description) from src/lib/metadata.ts. It repeats the shared Open Graph fields (site name, image) on every page, because Next.js replaces rather than merges a page's openGraph.
  • Set your domain for absolute social URLs: build with NEXT_PUBLIC_SITE_URL=https://your-domain.com, or edit the metadataBase fallback (https://northfield.example) in src/app/layout.tsx.
  • The Open Graph image path includes DEMO_BASE_PATH, because metadata URLs don't get the basePath added automatically.
  • Course, instructor and lesson pages are one static page each, so search engines see the default title. For per-course SEO, switch to dynamic routes with generateStaticParams() (see Pages).
  • Add src/app/sitemap.ts and src/app/robots.ts to generate sitemap.xml and robots.txt at build time.

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.
  • Mobile 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 disables reveals, count-ups and smooth scrolling.

When editing, keep one h1 per page, label every input, and check new colours for contrast in both themes.

Building and deploying#

sh
pnpm build      # static site in out/
pnpm start      # preview it at http://localhost:3000

next.config.ts sets output: "export" (plain HTML/CSS/JS in out/) and images: { unoptimized: true }.

  • Vercel: import the repository; no settings needed.
  • Netlify, Cloudflare Pages or any static host: build command pnpm build, publish directory out.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/your/path pnpm build; basePath is read from that variable.

Need server features (Route Handlers, Server Actions, authentication)? Remove output: "export" and images: { unoptimized: true } from next.config.ts and deploy to a Node.js host or Vercel.

TypeScript setup: package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript as an alias of @typescript/typescript6, the TypeScript 6 API that typescript-eslint and parts of the Next.js toolchain still need. eslint.config.mjs sets settings.react.version because eslint-plugin-react can't detect it under ESLint 10. When typescript-eslint supports TypeScript 7, replace both packages with a single typescript@7.

Troubleshooting#

  • useSearchParams() should be wrapped in a suspense boundary during build. A component that reads the URL must render inside <Suspense>, as the course, lesson, instructor and catalogue pages do.
  • The dashboard shows courses I never took. That's the sample progress; enrol or complete a lesson, or press Reset demo progress.
  • A hydration warning mentions the theme class. The boot script adds dark before React loads; the <html> element has suppressHydrationWarning for this. Keep it.
  • pnpm typecheck can't find route types. Run it through the script (next typegen runs first), not tsc alone.
  • Links break when hosted in a sub-folder. Build with DEMO_BASE_PATH set to that folder, and use next/link for internal links (plain <a href="/…"> doesn't get the base path).
  • Shared links show no image. Set NEXT_PUBLIC_SITE_URL to your domain 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#

MIT, like every Freeframe template: free for personal and commercial projects, no attribution required. The generated artwork is included under the same license. See LICENSE.txt in the download.

Search documentation

Search the guides and every template's documentation