Freeframe

Documentation

Academy

v1.0.2Updated Oct 9, 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, 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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.json

Each 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):

css
@theme {
  --color-leaf-600: #2563eb;
  --color-leaf-700: #1d4ed8;
  --color-leaf-800: #1e40af;
}

A few colours are not tokens:

  • The logo SVG in SiteHeader.astro and SiteFooter.astro uses #19693a and #ffc727 directly.
  • Topic colours (course covers, path cards) are TOPICS in src/data/catalog.ts, and the topic label text colours are TOPIC_TEXT next 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 from site.themeColor in src/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:

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:

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"] },

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 modules becomes a module with ids m1, m2, ….
  • If the course has a lessons array (one array of lesson titles per module), those titles are used. Otherwise every module gets five lessons named from LESSON_PATTERN (Why it matters, Core ideas, …).
  • Lesson ids are m<module>-l<lesson>, for example m2-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.

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:

html
---
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-toggle buttons. The dark: 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 (short under 5 hours, medium 5–10, long over 10), price (free, paid) and 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 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, topic and level (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-expanded on the button).
  • The result count is an aria-live region, 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 with aria-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 marked data-player, replacing the data-video-art artwork.

Pricing and team seats calculator#

/pricing, function pages.pricing.

  • The billing switch (data-billing-toggle, role="switch") swaps every [data-price] between its data-monthly and data-annual values, 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:

ts
// 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-view is 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 title and description to <Layout>, which turns them into <title>, the meta description, og:title, og:description, a summary_large_image Twitter card and theme-color.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://academy.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build
  • /course, /instructor and /lesson update document.title (ending in site.name from src/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 as src/pages/courses/[id].astro and getStaticPaths() over COURSES.

  • 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 a public/robots.txt that points to it.

Accessibility#

Built in:

  • A skip link to <main id="main">, landmarks (header, nav, main, footer) and one h1 per page.
  • Visible focus styles (:focus-visible outlines in global.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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /courses serves courses.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /courses to courses.html, change build.format to "directory" in astro.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 check points at the problem: its instructor must match an id in INSTRUCTORS and its topic must be a key of TOPICS.
  • A course page shows the first course instead. The ?id= in the link must match the course id exactly.
  • 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 with url() in pages and with BASE in main.ts.
  • /courses shows a 404 on my host. Set build.format: "directory" in astro.config.mjs and 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_URL to 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.

Search documentation

Search the guides and every template's documentation