Overview#
Nonprofit is a complete website for a charity. The fictional Clearwater Trust is a clean-water organization based on Washington Avenue in Minneapolis that funds community-run wells, springs and school water systems in nine countries. The site tells the story with count-up impact numbers, an appeal progress bar and long-form field stories, shows where every dollar goes, and has volunteering, events with RSVPs and a full donation flow. This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML template, and it generates to static files. The template page, with a link to the live demo, is at freeframe.dev/templates/nonprofit.
The look is warm and calm: cream paper, deep sea-green panels, a sunny yellow for the Donate buttons, Fraunces for headings and DM Sans for text, with drawn illustrations. It is a light-only design.
The site is a front end only: the forms check what is entered and show a confirmation, but nothing is sent and no payment is taken until you connect them (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/nonprofit into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with donors, four count-up impact numbers, mission checklist, three ways to help, a story spotlight, the Dry Season Appeal with its progress bar, partner logos, newsletter sign-up |
/about |
app/pages/about.vue |
Mission and vision, history timeline (2009 to 2026), leadership and board, "Where your money goes" with a donut chart (#finances) |
/programs |
app/pages/programs.vue |
Region filter, dot-matrix world map and six program cards (app/components/ProgramsExplorer.vue, #east-africa), how every project works |
/stories |
app/pages/stories.vue |
Featured story and five story cards |
/story |
app/pages/story.vue |
Long-form story with a drop cap, pull quote, figure, project facts, copy-link sharing and more stories |
/get-involved |
app/pages/get-involved.vue |
Four volunteer roles and the application form (#apply), fundraising steps (#fundraise), corporate partnerships (#partners) |
/events |
app/pages/events.vue |
Five upcoming events with RSVP buttons and the RSVP form with a guest stepper (#rsvp) |
/donate |
app/pages/donate.vue |
The donation form (app/components/DonateForm.vue) and the donor FAQ (#faq) |
| 404 | app/error.vue |
"This path has run dry." with links home and to the stories (generated as 404.html, noindex) |
Project structure#
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind (@tailwindcss/vite)
app/
app.vue <NuxtLayout> around <NuxtPage>
layouts/default.vue skip link, header, the page, footer, back-to-top button
error.vue the 404 page
router.options.ts writes query strings the way the HTML version does
pages/*.vue one file per page (see Pages)
components/
SiteHeader.vue sticky header, desktop menu, phone menu
SiteFooter.vue footer columns, newsletter, copyright year
LogoMark.vue the Clearwater Trust mark
NewsletterForm.vue the newsletter sign-up (home page card and footer)
CountUp.vue a count-up number
CampaignProgress.vue the appeal's figures and progress bar
ProgramsExplorer.vue region filter, map and program cards
DonateForm.vue the donation form
DonateCta.vue "Every $50 is a year of clean water" call to action
FieldError.vue a form field's error message
BackToTop.vue the back-to-top button
composables/
useValidation.ts inline form validation
useInView.ts runs something once an element is 40% in view
useCurrentPage.ts aria-current for menu links
usePageSeo.ts title, description, Open Graph and canonical tags
useAsset.ts paths to files in public/ with the base path
useYear.ts the copyright year
data/
site.ts menu, footer links, copyright year
appeal.ts the appeal's raised amount, goal and figures
programs.ts regions, program cards and their icons
map.ts the dot-matrix map, its region key and pins
events.ts upcoming events
giving.ts donation amounts, impact messages, fee rate, minimum gift, matched appeals, donor FAQ
utils/ money(), makeRef(), prefersReducedMotion()
plugins/
reveal.client.ts scroll reveals
keep-hash.client.ts keeps the address's #hash exactly as it was opened
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind entry, @font-face rules, brand tokens and component styles
assets/fonts/ Fraunces and DM Sans (woff2) with their licenses
public/img/ illustrations, favicon.svg, og-image.svg and og-image.pngCustomizing#
Brand and colors#
The design tokens are in the @theme block of app/assets/css/main.css:
| Token | Used for |
|---|---|
--color-cream, --color-sand |
page background and soft panels |
--color-ink, --color-muted, --color-line |
text, secondary text, borders |
--color-sea-50 to --color-sea-900 |
the brand green: headings, panels, buttons, the map |
--color-sun-100 to --color-sun-600 |
the Donate buttons, the progress bar, highlights |
--color-clay-400 to --color-clay-600 |
accents (map pins, the monthly heart) |
To change the brand green, edit the --color-sea-* values, for example:
@theme {
--color-sea-600: #1d5f8c;
--color-sea-700: #174d73;
--color-sea-800: #123d5c;
--color-sea-900: #0d2d45;
}Also update theme-color in nuxt.config.ts. Component styles (.btn-donate, .chip, .amount, .seg,
.progress-bar, .map) are in the same file.
Typography#
Fraunces (headings, with its optical-size axis) and DM Sans (text) are self-hosted from app/assets/fonts/ and
declared with @font-face at the top of main.css; --font-display and --font-sans in @theme use them. To
swap a font, add its woff2 file, change the @font-face rule and the token.
Content#
Most text is in the page files. Lists that drive interactive parts are typed data in app/data/:
PROGRAMSinprograms.ts: each program has aregion(a key ofREGIONS), anicon(a key ofICONS), atitle,textand astat.EVENTSinevents.ts:id(the RSVP form's option),date(ISO),day,month,format,title,text,when,whereandshort(the date in the RSVP list).AMOUNTS,DEFAULT_AMOUNT,IMPACT,FEE_RATE,MIN_GIFT,MATCHED_APPEALSandDONOR_FAQingiving.ts.APPEALinappeal.ts: the raised amount and goal (the bar fills toraised / goal), donors, days left and the matching funder.
Adding an event:
export const EVENTS: EventItem[] = [
// ...
{
id: "spring-walk",
date: "2027-04-17",
day: "17",
month: "Apr",
format: "In person",
title: "Spring Walk for Water",
text: "Carry a jerrycan for 5 km around Lake Harriet and see what millions walk every day.",
when: "Apr 17, 9:00 am",
where: "Lake Harriet, Minneapolis",
short: "Apr 17",
},
];The card and the RSVP form's option appear together. The volunteer roles are written in
app/pages/get-involved.vue; a new role needs its card (with apply('<Role>') on its button) and an <option>
with the same name in the form.
Images and artwork#
The illustrations in public/img/ are SVG drawings: the hero (hero.svg, 7:5), page headers (about.svg,
involved.svg, events.svg, donate.svg, 4:3), stories (story-*.svg, 4:3, and story-amina-2.svg, 12:7),
the call to action (cta.svg) and the 404 (lost.svg). Replace them with photos of the same aspect ratio and
keep descriptive alt text (or alt="" when decorative). Portraits on the home and about pages are inline SVG
in the page files. The favicon is public/img/favicon.svg; the social image is public/img/og-image.png
(1200 × 630), drawn from og-image.svg.
Navigation, header and footer#
The menu is NAV in app/data/site.ts (desktop and phone menus); SECTION marks pages that belong to a menu
item (the story page lights up Stories with aria-current="true"). Footer columns are FOOTER_COLUMNS and the
bottom links FOOTER_LEGAL. The header and footer markup is in SiteHeader.vue and SiteFooter.vue. To add a
page, create app/pages/<name>.vue and add its route to routes in nuxt.config.ts.
Features#
Donation form#
app/components/DonateForm.vue:
- "Give once" and "Monthly" switch the preset amounts (
AMOUNTS) and pickDEFAULT_AMOUNT($50 once, $25 monthly) unless an "Other amount" is typed. - A preset button or "Other amount" sets the gift; the impact message finds the first
IMPACTrow whoseminis at or below the amount ("$100 trains a local pump mechanic for a month."). Monthly gifts add "That's $300 of impact every year." - "Add $1.50 to cover processing fees" adds
FEE_RATE(3%) to the total; the summary and the button ("Donate $51.50 a month") follow every change. - "Dedicate my gift" opens the dedication fields and makes "Their name" required.
- The card fields format as you type (
4242 4242 4242 4242,12 / 30). On submit the card number is checked with the Luhn algorithm, the expiry must be a valid month that hasn't passed, the CVC 3 or 4 digits and a custom amount at leastMIN_GIFT($5). - On success the form shows "Thank you, Ada.", what the gift does and a reference like
CT-7K2Q, and the message receives focus. /donate?appeal=dry-seasonshows the matched-giving note; add appeal ids toMATCHED_APPEALS.
Buttons use aria-pressed, the impact message is a live region and the success message is role="status".
Impact numbers and the appeal#
CountUp.vue counts from zero to its value over 1.6 seconds once the number is 40% in view; the generated HTML
and reduced motion show the final number. CampaignProgress.vue fills the bar to raised / goal as it comes into
view and sets aria-valuenow; with reduced motion the bar jumps straight there.
Programs filter and map#
ProgramsExplorer.vue shows the programs of one region at a time. The buttons, the map's pins and the address all
choose the region: /programs#south-asia opens with South Asia, and every change is written back to the address
without adding history entries (the bare path for "All regions"). The map is drawn from MAP_ROWS in
app/data/map.ts, one string per row of 60 dots: . is sea, o is land and a letter is a dot in a region
(MAP_KEY); MAP_PINS places the pins. The chosen region's dots turn yellow and the others fade. The heading,
the region text and "Showing 2 programs" are a live region.
Volunteer roles and events#
"Apply for this role" chooses the role in the application form, scrolls to it and focuses the role field. On the events page, "RSVP" chooses the event, scrolls to the form and puts the cursor in "Full name"; the guest stepper keeps the number between 1 and 10. Scrolling is instant for reduced motion.
Story sharing#
"Copy link" copies the page address to the clipboard and says "Link copied" for 2.5 seconds (or how to copy it when the clipboard isn't available); "Email" opens a prefilled email.
Menu, header and motion#
The header gets a stronger background and a shadow after scrolling 10 px. Below 1024 px the menu button opens
the phone menu; Escape closes it and returns focus to the button, and following a link or widening the window
closes it too. .reveal elements fade up as they scroll into view (app/plugins/reveal.client.ts), with
everything above them, and --d sets a delay; reduced motion shows everything at once. The back-to-top button
appears after one screen of scrolling and moves focus to the main content.
Keeping the demo current#
The copyright year is written as 2026 in the generated HTML and replaced with the current year once the page
loads (useYear()). Event dates, the appeal and the history timeline are fixed content: update EVENTS,
APPEAL and the page text as your calendar moves on.
Forms#
All forms validate inline with useValidation() (app/composables/useValidation.ts), using the browser's own
rules (required, type="email", minlength, min/max, custom checks): errors appear on submit under each
field, the first invalid field is focused, and a field is checked again when you leave it and as you fix it.
| Form | Where | Fields checked |
|---|---|---|
| Newsletter (two variants) | app/components/NewsletterForm.vue |
first name (home page card), email |
| Volunteer application | app/pages/get-involved.vue |
name, email, role, a message of 20 characters or more, consent |
| RSVP | app/pages/events.vue |
event, name, email, guests (1 to 10) |
| Donation | app/components/DonateForm.vue |
see Donation form |
The demo forms don't send anything until you connect them. Each has a send() function; post the form there,
for example to Formspree, before showing the confirmation:
async function send(e: Event) {
const el = e.currentTarget as HTMLFormElement;
if (!v.validateForm(el)) return;
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(el),
headers: { Accept: "application/json" },
});
if (!res.ok) return;
sent.value = `Thank you! ... Your reference is ${makeRef()}.`;
el.reset();
}For donations, send the gift (amount, freq, fees) to your server and redirect to a payment page such as
Stripe Checkout instead of showing the thank-you message, and remove the demo card fields. Never collect card
numbers in your own form. With pnpm build you can add a Nuxt server route (server/api/checkout.post.ts) that
creates the Checkout session.
SEO and sharing#
Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title,
description, Open Graph and Twitter tags and a canonical link. URLs are absolute: set SITE_URL when you build
(SITE_URL=https://your-domain.example pnpm generate); the default is https://clearwatertrust.example. The
social image is public/img/og-image.png. The 404 page is noindex. Add a sitemap.xml and robots.txt to
public/ when you deploy, and consider NGO structured data with your address and EIN
(useHead({ script: [{ type: "application/ld+json", innerHTML: … }] })).
Accessibility#
- Skip link, landmarks, one
<h1>per page and a logical heading order. - Visible focus outlines in sun orange.
- Filter, frequency and amount buttons use
aria-pressed; the menu button usesaria-expandedandaria-controls; the dedication checkbox controls its fields witharia-expanded. - The region summary and the impact message are live regions; confirmations are
role="status". - Form errors are linked with
aria-describedbyand fields getaria-invalid. - The map and the spending chart are
role="img"with a title; decorative drawings are hidden. prefers-reduced-motionturns off reveals, count-ups and smooth scrolling.
When editing, keep one <h1> per page, label every new input and keep data in app/data/ so the pages and the
logic agree.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (donate.html,
linked as /donate), plus 404.html from app/error.vue. Next to each page is a folder with its
_payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host
(Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /donate to donate.html on their
own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/clearwater pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are
still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
- A form says "Thank you" but nothing arrives. The forms are not connected; see Forms.
- The matched-giving note doesn't show. Check that the
appealvalue in the link is listed inMATCHED_APPEALS. - A program doesn't appear under any region. Its
regionmust be a key ofREGIONSinapp/data/programs.ts. - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- A Tailwind class written in a template string has no effect. Write class names in full inside quotes; Tailwind can't find classes built from pieces.
- The illustrations show a different font. They load as
<img>, which cannot use web fonts; inline the SVG if you need the page fonts.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and
at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Fraunces and DM
Sans are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Clearwater Trust, its people,
partners, places and figures are fictional.