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).
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.jsonPages 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:
@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.
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:
{ 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
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:
FEATUREDat the top ofsrc/app/page.tsx. - Pricing plans, comparison table and FAQs:
PLANS,COMPAREandFAQinsrc/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:
FAQandREVIEWSinsrc/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.
Navigation, header and footer#
- Header links: the
NAVarray at the top ofsrc/components/site.tsx. Each item hashref,labelandmatch(the paths that mark it as current witharia-current="page"). - Footer columns: the
FooterColumnitems inSiteFooter, in the same file. - To add a page, create
src/app/<route>/page.tsx, exportmetadata = pageMetadata("Title", "Description"), and add it toNAVand/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 ofbootScript. - 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 (
shortunder 5 h,medium5–10 h,longover 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=Beginnerworks as a link.fromParams()andtoQuery()translate between the URL and theFiltersstate. - 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)fromsrc/lib/progress.tsand 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 inSITEfromsrc/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-progressclass with the--pCSS 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 witharia-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) showsCourseCoveras 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 therevealclass and reveals everything above an element too, so jumping down the page never leaves content hidden.CountUpanimates numbers once visible.useToast()fromcomponents/toast.tsxshows 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:
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:
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
metadatabuilt withpageMetadata(title, description)fromsrc/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'sopenGraph. - Set your domain for absolute social URLs: build with
NEXT_PUBLIC_SITE_URL=https://your-domain.com, or edit themetadataBasefallback (https://northfield.example) insrc/app/layout.tsx. - The Open Graph image path includes
DEMO_BASE_PATH, because metadata URLs don't get thebasePathadded 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.tsandsrc/app/robots.tsto generatesitemap.xmlandrobots.txtat build time.
Accessibility#
Built in:
- Skip link to
<main id="main">, landmarks, and oneh1per page. - Visible focus outlines and AA contrast in light and dark mode.
- Mobile 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 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#
pnpm build # static site in out/
pnpm start # preview it at http://localhost:3000next.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 directoryout. - Sub-folder hosting: build with
DEMO_BASE_PATH=/your/path pnpm build;basePathis 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 boundaryduring 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
darkbefore React loads; the<html>element hassuppressHydrationWarningfor this. Keep it. pnpm typecheckcan't find route types. Run it through the script (next typegenruns first), nottscalone.- Links break when hosted in a sub-folder. Build with
DEMO_BASE_PATHset to that folder, and usenext/linkfor internal links (plain<a href="/…">doesn't get the base path). - Shared links show no image. Set
NEXT_PUBLIC_SITE_URLto 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.