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, in light and dark themes. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.2. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/academy.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/academy into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Hero with course search, topic tiles with live course counts, featured courses, how it works, learning paths, count-up stats, instructors, testimonials, pricing teaser |
/courses |
src/pages/courses.astro |
Search, filters (topic, level, length, price, rating), six sort orders, grid/list view, filters kept in the URL, empty state |
/course?id=… |
src/pages/course.astro |
One course (for example /course?id=react-from-scratch): outcomes, curriculum accordion with free preview lessons, preview dialog, instructor, reviews histogram, FAQs, sticky enrol card, related courses |
/paths |
src/pages/paths.astro |
Each learning path's courses in order, total hours, weeks and bundle price |
/instructors |
src/pages/instructors.astro |
Instructor grid and the "Teach with us" form (#teach) |
/instructor?id=… |
src/pages/instructor.astro |
One instructor (for example /instructor?id=hana): bio, stats, tags, their courses, other instructors |
/pricing |
src/pages/pricing.astro |
Monthly/yearly toggle, team seats calculator, team quote form (#teams), comparison table, FAQ (#faq) |
/learn |
src/pages/learn.astro |
Student dashboard ("My learning"): stats, streak, continue where you left off, progress rings, certificates, recommendations, reset |
/lesson?course=…&lesson=… |
src/pages/lesson.astro |
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 enrolled |
| 404 | src/pages/404.astro |
Course search and suggested courses |
/course, /instructor and /lesson are single pages that fill themselves in the browser from the data, using
the query string. An unknown or missing id falls back to the first course or instructor.
Project structure#
academy/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts, theme script), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, navigation, theme toggle, "Join for free", mobile menu
│ │ └─ SiteFooter.astro footer links, newsletter, copyright
│ ├─ data/
│ │ └─ catalog.ts topics, instructors, courses, learning paths and the curriculum helpers
│ ├─ lib/
│ │ └─ site.ts site name, theme colours and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts generated artwork, page renderers, saved progress and all interactions
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (@theme tokens) and component classes
├─ public/img/ favicon.svg, og-image.png (and its og-image.svg source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonEach page passes a page prop to <Layout>, which becomes <body data-page="…"> (home, courses, course,
paths, instructors, instructor, pricing, learn, lesson, notfound). src/scripts/main.ts runs the
matching function in its pages object (pages.home, pages.courses, …), so a page only renders what it
needs. Course cards, covers, portraits and the dashboard are rendered by that script in the browser.
Customising#
Brand and colours#
All design tokens live in the @theme block of src/styles/global.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 |
To change the brand colour, replace the leaf scale with your own (keep the 50–950 steps):
@theme {
--color-leaf-600: #2563eb;
--color-leaf-700: #1d4ed8;
--color-leaf-800: #1e40af;
}A few colours are not tokens:
- The logo SVG in
SiteHeader.astroandSiteFooter.astrouses#19693aand#ffc727directly. - Topic colours (course covers, path cards) are
TOPICSinsrc/data/catalog.ts, and the topic label text colours areTOPIC_TEXTnext to it (chosen for AA contrast in light and dark). - The certificate artwork in
certificate()(src/scripts/main.ts) uses the brand green and yellow directly. <meta name="theme-color">comes fromsite.themeColorinsrc/lib/site.ts.
Typography#
Two Google Fonts are loaded by the <link rel="stylesheet"> in src/layouts/Layout.astro: Plus Jakarta
Sans (all text) and Caveat (the handwritten accents, class hand). They're mapped in global.css:
--font-sans: "Plus Jakarta Sans", ui-sans-serif, system-ui, …;
--font-hand: "Caveat", "Segoe Print", "Bradley Hand", cursive;To swap a font, change the Google Fonts URL in the layout and the token.
Content#
Static text (hero copy, FAQs, testimonials, plan descriptions) is markup in src/pages/. Everything about
courses comes from src/data/catalog.ts (the same data as the Next.js version):
| Export | Contents |
|---|---|
TOPICS |
The five topics (design, frontend, backend, data, product) with name and art colours |
INSTRUCTORS |
id, name, role, topic, portrait colours (skin, hair, style), rating, students, bio, tags |
COURSES |
id, title, topic, level, hours, price (0 = free), rating, reviews, students, instructor (an instructor id), optional badge, updated, summary, outcomes, modules, optional lessons |
PATHS |
id, name, topic, weeks, blurb, courses (course ids, in order) |
The Course, Instructor and LearningPath types make TypeScript check every entry. Add a course by adding
an object to COURSES:
{ 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"] },It then appears in the catalogue, on /course?id=figma-basics, in search, on its instructor's page and, if you
add its id to a path, in that path.
How the curriculum works (curriculum(course) in catalog.ts):
- Each entry in
modulesbecomes a module with idsm1,m2, …. - If the course has a
lessonsarray (one array of lesson titles per module), those titles are used. Otherwise every module gets five lessons named fromLESSON_PATTERN(Why it matters,Core ideas, …). - Lesson ids are
m<module>-l<lesson>, for examplem2-l3. - Lesson lengths are generated from the course id so they add up to
hours, and never change between visits. - The first two lessons of the first module are free previews.
Lesson ids are also what saved progress refers to, so if you rename or reorder modules on a live site, existing visitors' progress for that course may point at different lessons (unknown ids are dropped, see Saved data).
The featured courses on the home page are the ids in featured at the start of pages.home. The headline
"Twelve courses …" in courses.astro and the stats on the home page are plain text, so update them when your
catalogue changes.
Prices on the pricing cards are written in pricing.astro as data-monthly and data-annual attributes. The
team calculator's per-seat prices are separate: const base = annual ? 24 : 29; in pages.pricing, with the
volume discount tiers on the line below. Change both places together.
Images and artwork#
There are no image files for courses or people. All of it is SVG drawn by functions in src/scripts/main.ts:
| Function | Draws | Notes |
|---|---|---|
cover(course) |
Course cover art (320×200 viewBox, used at 16:10) | Colours from the course's topic; two alternating motifs per topic |
avatar(person, size) |
Instructor portrait (square, circular crop) | Uses the instructor's skin, hair and style (0–5) |
stars(rating) |
Star rating with partial fill | Has a text alternative ("Rated 4.8 out of 5") |
certificate(course, name) |
Certificate of completion (560×380) | Shown on the dashboard |
To use photos, put them in public/img/ and return an <img> from cover() (keep 16:10, for example
1280×800) and avatar() (square, at least 320×320). Build the src with the base path, as the script does for
links: BASE + "/img/covers/" + course.id + ".jpg". Give the <img> meaningful alt text, or alt="" when it
is decorative, as the cards do.
The dashboard's student name, Alex Morgan, appears on certificates and in the welcome text (search
learn.astro and main.ts for Alex). Replace it with the signed-in user's name when you connect real
accounts.
Sharing image: public/img/og-image.png (1200×630) is generated from og-image.svg. Edit the SVG, then export
it again as a 1200×630 PNG. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The header is src/components/SiteHeader.astro (desktop menu <nav aria-label="Main">, theme toggle, "Join
for free" and the mobile menu #mobile-menu); the footer is src/components/SiteFooter.astro. A header link
is marked aria-current="page" when the page's current prop matches it, for example
aria-current={current === "paths" ? "page" : undefined}.
To add a page, create src/pages/faq.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="FAQ — Northfield Academy" description="Answers to common questions." page="faq" current="faq">
<section class="wrap py-16">
<h1 class="h-display text-4xl">FAQ</h1>
</section>
</Layout>Then add links to the desktop menu, the mobile menu and the footer. Write internal links as
href={url("/faq")} (from src/lib/site.ts) so they work under a base path. If the page needs JavaScript, add
a pages.faq function in main.ts; it runs on pages whose page prop is "faq".
Dark mode#
A small <script is:inline> in the layout's <head> applies the saved theme before the page paints, so
there's no flash. It reads localStorage key theme ("dark" or "light"); with nothing saved it follows
the visitor's system setting. The toggle buttons have data-theme-toggle; main.ts flips the dark class,
updates aria-pressed and the label, and saves the choice.
- Default to light: in the inline script, remove
|| (!t && window.matchMedia("(prefers-color-scheme: dark)").matches). - Remove dark mode: delete the theme lines of the inline script and the
data-theme-togglebuttons. Thedark:classes then never apply.
Features#
All feature code is in src/scripts/main.ts.
Course catalogue#
/courses, function pages.courses.
- Search matches the course title, summary, topic name and instructor name.
- Filters: topic and level (checkboxes, any number), length (
shortunder 5 hours,medium5–10,longover 10), price (free,paid) and minimum rating (4.8,4.7). - Sort:
popular(most students, the default),rating,newest(order inCOURSES, last first),length,price-asc,price-desc. - The state is written to the URL with
history.replaceState, so searches can be shared and the home page topic tiles can link to/courses?topic=data. Supported parameters:q,topicandlevel(comma-separated),length,price,rating,sort. - Grid or list view is saved in
localStorage(academy-view). - On small screens the filters are behind a "Filters" button (
aria-expandedon the button). - The result count is an
aria-liveregion, so screen readers hear "5 courses" after each change.
To add a filter value, add the input in courses.astro and the matching condition in the apply() function.
Course page#
/course, function pages.course.
- Elements with
data-bind="…"are filled from the course (title, summary, level, hours, price and so on). - The curriculum is native
<details>elements, so it works with the keyboard and screen readers without extra code. "Expand all" toggles every module. - Preview lessons link to the lesson player; the "Preview this course" dialog (
#preview-dialog, a native<dialog>) lists them. Escape and clicking the backdrop close it. - The review histogram is generated to match the course's rating.
- Enrol (
data-enrol) saves the enrolment in the browser and shows a toast. It doesn't take payment. Once enrolled, the button becomes "Continue learning" and opens the next lesson. - The enrol card is sticky on large screens and marked
data-back-to-top-hide, so the back-to-top button never covers it.
Learning paths#
/paths, function pages.paths. Each item in PATHS becomes a section with an anchor (/paths#data-analyst).
Hours and the bundle price are added up from the courses; weeks is your own estimate.
Student dashboard#
/learn, function pages.learn.
- Stats (lessons completed, hours learned, certificates) are calculated from saved progress.
- The learning streak counts consecutive days with a completed lesson; the week strip marks the days.
- Continue learning picks the first course still in progress and links to its last lesson.
- Each enrolled course shows a progress ring (
.ring-progress, driven by the CSS variable--p). - Finished courses earn a certificate, shown in a dialog (
#cert-dialog). - Reset demo progress (
data-reset) clears saved progress and notes.
Until a visitor does something themselves, the dashboard shows sample progress (the DEMO object in
main.ts) so it never looks empty, with a note explaining it. Their first enrolment or completed lesson
replaces the sample with their own progress (makeOwn()).
Lesson player#
/lesson, function pages.lesson.
- The sidebar lists every module and lesson with completion ticks, and marks the current one with
aria-current="page". On small screens it's a drawer opened by the "Course contents" button. - Mark as complete (
data-complete) toggles the lesson, updates the streak and moves "last lesson" to the next unfinished one. Its state is exposed witharia-pressed. - Lessons other than previews are locked until the visitor enrols (or completes a preview lesson).
- Tabs (Overview, Notes, Resources) follow the ARIA tabs pattern: arrow keys, Home and End move between
them (
setupTabs()). - Notes save automatically as you type (after a short pause) and are kept per lesson.
- The video area is a placeholder: put your player (an
<iframe>or<video>) inside the element markeddata-player, replacing thedata-video-artartwork.
Pricing and team seats calculator#
/pricing, function pages.pricing.
- The billing switch (
data-billing-toggle,role="switch") swaps every[data-price]between itsdata-monthlyanddata-annualvalues, and the notes under them. - The seats slider (
#seats, 2–200) and number input (#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: $24 (yearly) or $29 (monthly), less 5% from 10 seats, 10% from 50 and 15% from 200.
Scroll reveals and counters#
Elements with the class reveal fade in as they scroll into view; elements with data-count (plus optional
data-decimals and data-suffix) count up from zero. Revealing an element also reveals everything above it,
so jumping to the end of a page never leaves content hidden. With reduced motion, everything is shown at once.
Back to top button#
src/scripts/back-to-top.ts adds a "Back to top" button after the visitor scrolls about one screen. It scrolls
back up (instantly with reduced motion), moves focus to <main id="main"> and lifts itself above toasts. Add
data-back-to-top-avoid to anything pinned to the bottom of the screen, or data-back-to-top-hide to a sticky
element it must never cover. To remove it, delete the file, its import in Layout.astro and the
.back-to-top rules in global.css.
Forms#
| Form | Where | Validates |
|---|---|---|
| Newsletter | Footer of every page (data-newsletter) |
Email present and well-formed |
| Course search | Home, catalogue, 404 (data-search-form) |
An empty search goes to the full catalogue |
| Teach with us | /instructors#teach (data-teach-form) |
Name, email, topic chosen, idea at least 20 characters |
| Team quote | /pricing#teams (data-team-form) |
Name, work email, company or school |
Errors appear under each field (fieldError() sets aria-invalid and aria-describedby), and focus moves to
the first problem. On success the form shows a message. The forms don't send anything until you connect
them.
To connect a form, find its success branch in main.ts (the newsletter and team forms have a "Demo only"
comment, the teach form a "demo:" comment) and post the data. For example, with
Formspree, in the team form's submit handler in pages.pricing:
// Replace the "Demo only" comment and the lines after it:
fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
})
.then((res) => {
if (!res.ok) throw new Error("Request failed");
form.hidden = true;
const done = $("[data-team-success]");
done.hidden = false;
done.focus();
})
.catch(() => alert("Sorry, something went wrong. Please try again."));Make sure every input has a name attribute so FormData includes it, and keep the success message only for
a real success. To run your own server code instead, add an Astro adapter (for example @astrojs/node,
@astrojs/netlify or @astrojs/vercel) and an API endpoint
such as src/pages/api/quote.ts with export const prerender = false, then post to it.
Enrolment and progress are also front-end only: to support real accounts, replace the functions in the
"Progress" section of main.ts with calls to your backend.
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, mark as complete, opening a lesson |
academy-notes |
{ "<course id>/<lesson id>": "note text" } (each note at most 5,000 characters) |
Lesson notes |
academy-view |
"grid" or "list" |
Catalogue view buttons |
theme |
"dark" or "light" |
Theme toggle |
Everything is validated when read (cleanProgress() and loadNotes(), typed with the Progress type):
- Unknown courses, unknown lesson ids, duplicates and non-string values 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. - If anything had to be dropped, the cleaned value is written back (self-healing). Unreadable JSON is removed.
academy-viewis only used when it is exactly"list"; anything else means grid.
To reset, use Reset demo progress on /learn, or run localStorage.clear() in the browser console. To
stop saving altogether, remove the writeJSON calls.
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>, which turns them into<title>, the meta description,og:title,og:description, asummary_large_imageTwitter card andtheme-color. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://academy.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm build -
/course,/instructorand/lessonupdatedocument.title(ending insite.namefromsrc/lib/site.ts, and the course page its meta description) in the browser, but crawlers that don't run JavaScript only see the defaults. For real SEO on individual courses, generate one page per course with a dynamic route such assrc/pages/courses/[id].astroandgetStaticPaths()overCOURSES. -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap) and apublic/robots.txtthat points to it.
Accessibility#
Built in:
- A skip link to
<main id="main">, landmarks (header,nav,main,footer) and oneh1per page. - Visible focus styles (
:focus-visibleoutlines inglobal.css) and AA colour contrast in both themes. - The mobile menu sets
aria-expanded, closes with Escape and returns focus to its button. - Tabs, dialogs, the billing switch and the progress bars use the matching ARIA patterns.
- Star ratings, progress rings and covers have text alternatives; decorative SVGs are
aria-hidden. - Live regions announce result counts and toasts.
- Reduced motion turns off reveals, count-ups and smooth scrolling.
When you edit, keep one h1 per page, give every new input a <label>, and check new colours for contrast in
light and dark mode.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as courses.html rather than
courses/index.html (build.format: "file" in astro.config.mjs), and links point to /courses without a
trailing slash. compressHTML: false keeps whitespace exactly as written, so text that continues on the next
line before an inline element keeps its space. Tailwind finds the class names inside src/scripts/main.ts as
well as in the pages, so classes used by the rendered cards are included.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/coursesservescourses.html. - GitHub Pages: use the official Astro GitHub Action.
For a project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/coursestocourses.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/academy pnpm build. It becomes Astro's base: links written with url() include it, and so
do the links the script builds (BASE in main.ts).
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides
the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6
API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a
single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- The dashboard shows courses I never took. That's the sample progress. Enrol in a course or complete a lesson, or press Reset demo progress.
- My new course doesn't appear, or the build fails.
pnpm checkpoints at the problem: itsinstructormust match anidinINSTRUCTORSand itstopicmust be a key ofTOPICS. - A course page shows the first course instead. The
?id=in the link must match the courseidexactly. - Progress disappeared after I renamed modules. Lesson ids come from module and lesson positions; renaming or reordering them changes which lessons saved ids point to. Unknown ids are dropped on the next visit.
- Links break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write links withurl()in pages and withBASEinmain.ts. /coursesshows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand rebuild.- The theme flashes on load. Keep the
<script is:inline>in the layout's<head>. - Shared links show the wrong domain or no image. Set
SITE_URLto your domain at build time.
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.