Overview#
Nonprofit is a complete website for a charity, foundation or community organisation, shown as Clearwater Trust, a fictional clean-water nonprofit working with rural communities in nine countries. It tells the story with animated impact counters, a live appeal progress bar and long-form field stories, shows where the money goes with a spending chart, and includes programs with a region filter and map, volunteering, events with RSVPs and a full donation flow (one-time or monthly, preset or custom amounts, an impact message for every amount, cover-the-fees and dedications). This is the Next.js version (1.0.0): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.
Live demo: freeframe.dev/templates/nonprofit
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
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 (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/nonprofit into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with donor portraits, count-up impact numbers, mission checklist, three featured programs, story spotlight, Dry Season Appeal with progress bar, partner logos, newsletter sign-up |
/about |
src/app/about/page.tsx |
Mission and vision, timeline, leadership and board, "Where your money goes" with a donut chart (#finances) |
/programs |
src/app/programs/page.tsx |
Region filter, dot-matrix map and program cards (ProgramsExplorer), how every project works |
/stories |
src/app/stories/page.tsx |
Featured story and a grid of stories |
/story |
src/app/story/page.tsx |
Long-form story with drop cap, pull quote, figure, project facts, copy-link sharing, more stories |
/get-involved |
src/app/get-involved/page.tsx |
Volunteer roles and application form (Volunteer), fundraising ideas (#fundraise), corporate partnerships (#partners) |
/events |
src/app/events/page.tsx |
Upcoming events with RSVP buttons and a sticky RSVP form with a guest stepper (EventsBoard) |
/donate |
src/app/donate/page.tsx |
Donation form (DonateForm) and donor FAQ (#faq) |
| 404 | src/app/not-found.tsx |
"This path has run dry" page |
/story is a single example that every story card links to. See Content for turning it into one page per
story.
Project structure#
src/
├── app/
│ ├── layout.tsx fonts, default metadata, header, footer, scroll reveals, back-to-top
│ ├── page.tsx home
│ ├── <route>/page.tsx one folder per page
│ ├── not-found.tsx 404 page
│ ├── globals.css Tailwind import, brand tokens (@theme) and component classes
│ └── icon.svg favicon
├── components/
│ ├── SiteHeader.tsx logo, menu, Donate button, mobile menu (client)
│ ├── SiteFooter.tsx footer columns, newsletter, legal line
│ ├── PageHero.tsx header of the inner pages
│ ├── DonateCta.tsx "Every $50 is a year of clean water" band
│ ├── ProgramsExplorer.tsx region filter, map and program cards (client)
│ ├── DonateForm.tsx the donation flow (client)
│ ├── Volunteer.tsx roles and application form (client)
│ ├── EventsBoard.tsx events, RSVP buttons and form (client)
│ ├── NewsletterForm.tsx home page card and footer sign-up (client)
│ ├── CountUp.tsx, CampaignProgress.tsx animated numbers and appeal bar (client)
│ ├── SpendingChart.tsx donut chart from the spending data
│ ├── StoryCard.tsx, PartnerLogos.tsx, Portrait.tsx, Icon.tsx, Logo.tsx
│ ├── ShareStory.tsx copy link and email sharing (client)
│ ├── RevealObserver.tsx, BackToTop.tsx, Year.tsx site-wide behaviour (client)
├── data/
│ ├── site.ts name, address, menu and footer links
│ ├── programs.ts REGIONS, PROGRAMS, PROCESS
│ ├── map.ts the dot-matrix map (MAP_ROWS, MAP_KEY, MAP_PINS)
│ ├── stories.ts FEATURED_STORY, STORIES
│ ├── events.ts EVENTS
│ ├── giving.ts donation amounts, impact messages, fees, matched appeals, donor FAQ
│ └── organisation.ts impact numbers, appeal, timeline, people, spending, partners, volunteering
├── lib/
│ ├── asset.ts prefixes public files with the base path
│ ├── metadata.ts pageMetadata(): title, description and Open Graph per page
│ ├── use-validation.tsx form validation hook
│ ├── in-view.ts onceInView() for counters and the progress bar
│ └── style.ts delay() for reveal staggers, cx() for class names
└── fonts/ Fraunces and DM Sans (woff2) with their licences
public/img/ SVG illustrations, og-image.svg and og-image.pngOther files: next.config.ts (static export and base path), eslint.config.mjs, postcss.config.mjs,
tsconfig.json, template.json (gallery metadata), pnpm-workspace.yaml (marks the folder as a standalone project).
Customising#
Brand and colours#
The design tokens are in the @theme block at the top of src/app/globals.css. Tailwind turns each one into
utilities (bg-sea-700, text-sun-300, border-line...):
| Token | Used for |
|---|---|
--color-cream, --color-sand |
Page background, soft panels |
--color-ink, --color-muted, --color-line |
Text, secondary text, hairlines |
--color-sea-50 … --color-sea-900 |
The brand teal: buttons, headings, dark bands, the map |
--color-sun-100 … --color-sun-600 |
Warm accents: Donate buttons, eyebrows, progress bar, focus ring |
--color-clay-400 … --color-clay-600 |
Map pins, small highlights |
To change the brand colour, replace the sea scale with your own (keep the light-to-dark order):
@theme {
--color-sea-600: #1f5f8b;
--color-sea-700: #1a5077;
--color-sea-800: #163f5e;
--color-sea-900: #10304a;
}A few colours are set directly where they're drawn: the logo (src/components/Logo.tsx), the portraits
(Portrait colours in src/data/organisation.ts), the spending chart (color in SPENDING), the partner marks
(src/components/PartnerLogos.tsx) and theme-color (viewport in src/app/layout.tsx). Component classes such as
.btn-donate, .chip, .amount, .field and .progress-bar are in the @layer components block of globals.css.
Typography#
Headings use Fraunces (with italics) and text uses DM Sans. Both are self-hosted from src/fonts/ with
next/font/local in src/app/layout.tsx (the same Latin files Google Fonts serves, so text wraps exactly like the HTML
version) and exposed as --font-fraunces and --font-dm-sans, which --font-display and --font-sans use in
globals.css.
To swap a font, either put another woff2 in src/fonts/ and point localFont at it, or use next/font/google:
import { Lora } from "next/font/google";
const lora = Lora({ subsets: ["latin"], variable: "--font-fraunces", display: "swap" });The spending chart's centre labels use var(--font-display) and var(--font-sans), so they follow the change.
Content#
Text that appears once lives in the page files (src/app/**/page.tsx). Anything repeated or listed is in typed arrays
in src/data/:
| File | Exports | Shown on |
|---|---|---|
site.ts |
site (name, address, email, phone, legal line), NAV, SECTION, FOOTER_COLUMNS, FOOTER_LEGAL |
Every page |
programs.ts |
REGIONS, PROGRAMS, PROCESS |
Home, programs |
map.ts |
MAP_ROWS, MAP_KEY, MAP_PINS |
Programs map |
stories.ts |
FEATURED_STORY, STORIES |
Home, stories, story |
events.ts |
EVENTS |
Events (cards and the RSVP list) |
giving.ts |
AMOUNTS, DEFAULT_AMOUNT, FEE_RATE, MIN_GIFT, IMPACT, MATCHED_APPEALS, DONOR_FAQ |
Donate |
organisation.ts |
IMPACT_NUMBERS, MISSION_POINTS, APPEAL, DONOR_PORTRAITS, TIMELINE, LEADERSHIP, BOARD, SPENDING, FINANCE_BADGES, FINANCE_FIGURES, PARTNERS, ROLES, AVAILABILITY, FUNDRAISING_IDEAS, PARTNERSHIPS |
Home, about, get involved |
Adding a program: append to PROGRAMS. region must be a key of REGIONS and icon a name from ICONS in
src/components/Icon.tsx. Set featured: true to show it on the home page (the home page shows every featured
program).
{
region: "latin-america",
icon: "drop",
title: "School water and sanitation",
text: "Taps, toilets and hand-washing stations in rural schools.",
stat: "38 schools served",
},Adding an event: append to EVENTS in src/data/events.ts. id becomes the value of the RSVP form's option and of
the card's RSVP button, so keep it unique:
{
id: "open-day",
date: "2027-02-20",
day: "20",
month: "Feb",
format: "In person",
title: "Open day at our Portland office",
text: "Meet the team and hear from our field partners.",
when: "Feb 20, 11:00 am",
where: "2140 SE Water Ave, Portland",
short: "Feb 20",
},Other common edits:
- Appeal: change
APPEALinorganisation.ts.raisedandgoaldrive the figures,aria-valuenowand the bar;idis used in the "Double my gift" link (/donate?appeal=dry-season). Add the same id toMATCHED_APPEALSingiving.tsto show the "your gift will be doubled" note on the donate page. - Impact numbers:
IMPACT_NUMBERS(valueis a plain number;suffixis optional, e.g."%"). - Spending:
SPENDINGrows (percentages should add up to 100). The list and the donut chart are drawn from it. - People:
LEADERSHIPandDONOR_PORTRAITSuse illustrated portraits (Portrait: background, shirt, skin and hair colours, and a hair style of"short","cap"or"long"). To use photos, replace<Portrait>with an<img>insrc/app/about/page.tsxandsrc/app/page.tsx. - Stories:
STORIEScards all link to/story. To give each story its own page, add aslugtoStory, createsrc/app/stories/[slug]/page.tsxwithgenerateStaticParams()returning every slug (required by static export), move the story text into data or MDX, and link cards to`/stories/${story.slug}`.
Images and artwork#
All illustrations are SVG files in public/img/; always reference them through asset() (see
Building and deploying). When replacing them with photos, keep the aspect ratios:
| Image | Ratio | Where |
|---|---|---|
hero.svg |
7:5 | Home hero |
about.svg, involved.svg, events.svg, donate.svg |
4:3 | Page headers, home mission |
story-*.svg |
4:3 (story-amina-2.svg 12:7) |
Story cards and the story page |
cta.svg |
12:5, cropped from the bottom | DonateCta background |
lost.svg |
3:2, covers the panel | 404 |
The social image is public/img/og-image.png (1200×630), exported from og-image.svg. The favicon is
src/app/icon.svg; Next.js links it automatically. The map is drawn from MAP_ROWS (see
Programs filter and map).
Navigation, header and footer#
The menu is NAV in src/data/site.ts; SiteHeader renders it for desktop and mobile and marks the current page with
aria-current="page". Pages that belong to a section without being its page are listed in SECTION (the story page
belongs to Stories) and get aria-current="true". The Donate button is part of SiteHeader. Footer link columns are
FOOTER_COLUMNS and the legal links FOOTER_LEGAL; the contact details come from site. To add a page, create
src/app/<route>/page.tsx and add it to NAV (and template.json if you publish the template).
Features#
Donation form#
src/components/DonateForm.tsx (client component) on /donate:
- Frequency: "Give once" and "Monthly" toggle buttons (
aria-pressed). Switching shows that frequency'sAMOUNTSand, unless a custom amount is typed, selectsDEFAULT_AMOUNT($50 once, $25 monthly). - Amount: preset buttons or "Other amount" (minimum
MIN_GIFT, $5). Typing deselects the presets. - Impact message: the first
IMPACTrow whoseminis at or below the gift (impactFor()), with "a month" and the yearly total for monthly gifts. It's in a polite live region. - Fees: "cover processing fees" adds
FEE_RATE(3%) of the gift; the summary shows gift, fees and total, and the submit button says "Donate $51.50" (or "… a month"). - Dedication: ticking "Dedicate my gift" reveals the dedication fields (
aria-expandedon the checkbox), makes the name required and focuses it. - Demo card fields: the card number is grouped in fours and checked with the Luhn algorithm (4242 4242 4242 4242
passes), the expiry is formatted as
MM / YYand must be in the future, and the CVC must be 3 or 4 digits. - Matched appeals:
/donate?appeal=<id>shows the "Dry Season Appeal" note when the id is inMATCHED_APPEALS.
On a valid submit it shows a focused thank-you message with the gift and its impact. No payment is taken. To take
real donations, replace the end of onSubmit with your payment provider, for example a Stripe Checkout session
created in a Route Handler (which needs the static export removed, see Building and deploying)
or a hosted donation page you redirect to.
Programs filter and map#
ProgramsExplorer (client) on /programs: the region buttons (aria-pressed) show the matching program cards, update
the region title, summary and "Showing 3 programs" count (a polite live region) and highlight the region on the map.
Clicking a pin on the map filters too. The region is kept in the URL hash (/programs#east-africa), so the home
page's program cards link straight to a region; "All regions" removes the hash.
The map is a 60 × 22 grid of dots. Each string in MAP_ROWS is one row: . is sea, o land, and e, s, a, l
are dots in a region (MAP_KEY). MAP_PINS places the markers. To add a region, add it to REGIONS, give it a letter
in MAP_KEY, paint its dots in MAP_ROWS and add a pin.
Count-up numbers and appeal progress#
CountUp counts a number up from 0 over 1.6 seconds the first time it is 40% visible (onceInView() in
src/lib/in-view.ts); the final value is in the HTML, so it's correct without JavaScript. CampaignProgress fills the
appeal bar to the raised percentage the same way. With reduced motion, numbers show their value at once and the bar
doesn't animate.
Volunteer roles and RSVPs#
On /get-involved, "Apply for this role" (Volunteer) selects the role in the application form, scrolls to it and
focuses the role field. On /events, each RSVP button (EventsBoard) selects the event in the RSVP form, scrolls to
it and focuses the name field. The guest stepper's − and + buttons change the number between 1 and 10; the field can
also be typed in.
Story sharing#
ShareStory on /story: "Copy link" copies the page address and announces "Link copied" in a status message; "Email"
opens a pre-filled email.
Header, mobile menu and scroll reveals#
SiteHeader gets a shadow once the page scrolls (.is-scrolled). Below 1024px the menu button opens #mobile-menu
(aria-expanded, label "Open menu"/"Close menu"); Escape closes it and returns focus to the button, and it closes on
navigation or when the window gets wider. Elements with the reveal class fade in as they scroll into view
(RevealObserver, with delay() setting a stagger); everything earlier on the page reveals too, so jumping to an
anchor never leaves hidden content. Without JavaScript, or with reduced motion, everything is visible.
Back to top#
BackToTop appears after you scroll about one screen, scrolls back to the top (instantly with reduced motion) and
moves focus to <main>. Remove it by deleting <BackToTop /> in src/app/layout.tsx and the .back-to-top styles
in globals.css.
Forms#
| Form | Component | Validates |
|---|---|---|
| Newsletter (home card) | NewsletterForm variant="card" |
First name, email |
| Newsletter (footer) | NewsletterForm variant="footer" |
|
| Volunteer application | Volunteer (#apply) |
Name, email, role, a message of at least 20 characters, the consent checkbox |
| RSVP | EventsBoard (#rsvp) |
Event, name, email, guests (1–10) |
| Donation | DonateForm (#donate-form) |
Amount (at least $5), dedication name if dedicating, names, email, card, expiry, CVC |
All of them use useValidation() from src/lib/use-validation.tsx: the browser's own constraints (required,
type="email", minLength, min/max, pattern and setCustomValidity) with friendly messages. A field is checked
when it loses focus (once it has a value) and on every change while it's invalid; the message appears under it as
<p class="field-error" id="<field id>-error">, linked with aria-describedby and aria-invalid. Submitting checks
every visible field and focuses the first problem. On success the form resets and a thank-you message appears in a
role="status" element.
Nothing is sent. To receive submissions with a form service such as Formspree, post the
form in its onSubmit where the demo comment is, for example in Volunteer.tsx:
async function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
if (!v.validateForm(form)) return;
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
setSent(true);
form.reset();
}Update the success texts too: they say the form is a demo. If you remove the static export, you can post to a
Route Handler (e.g. src/app/api/volunteer/route.ts) or call a Server Action instead. Connect the newsletter
forms to your email provider the same way.
SEO and sharing#
- Defaults are in
metadatainsrc/app/layout.tsx: a title template (%s — Clearwater Trust), description and Open Graph image. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which also sets the Open Graph title and description. The home page sets its full title ("Clearwater Trust — Safe water, for good"). metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tosite.urlinsrc/data/site.ts). Set it when you build so the social image URL is absolute:
NEXT_PUBLIC_SITE_URL=https://www.your-charity.org pnpm buildThe 404 page is marked noindex by Next.js. Consider adding src/app/sitemap.ts and src/app/robots.ts (both work
with static export) and NGO structured data (JSON-LD) on the home page.
Accessibility#
Built in: a skip link and landmarks, one h1 per page, visible focus rings, aria-current in the navigation, toggle
buttons with aria-pressed (frequency, amounts, region filters), live regions for the impact message and program
count, a titled map and a titled, labelled donut chart, form errors linked with aria-describedby and focus moved to
the first error, a focused thank-you message after donating, an accessible mobile menu (Escape closes it), native
<details> for the FAQ, and reduced motion respected for reveals, counters, the progress bar and scrolling.
When editing, keep one h1 per page, label every input, write alt text for informative images (decorative ones keep
alt=""), and check contrast if you change the palette (use sun-600, not sun-400, for warm text on light
backgrounds).
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts):
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve from a sub-folder, build with DEMO_BASE_PATH=/charity pnpm build; next.config.ts passes it to basePath
and NEXT_PUBLIC_BASE_PATH, which asset() uses for files in public/. pnpm build:demo does this for the
freeframe.dev demo (demo/, which you can delete).
To use server features (Route Handlers, Server Actions, a payment provider's server SDK), remove output: "export" and
images.unoptimized from next.config.ts and deploy to a Node-capable host such as Vercel.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of
@typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6
API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins
settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.
Troubleshooting#
- A new program doesn't show for its region.
regionmust be one of the keys ofREGIONS("east-africa"), not its title. - A new icon name fails to type-check.
iconmust be a key ofICONSinsrc/components/Icon.tsx; add the path there first. - The RSVP button selects nothing. The event's
idmust match an option; both come fromEVENTS, so check for a duplicatedid. - The "doubled" note doesn't appear. The
?appeal=value must be listed inMATCHED_APPEALSingiving.ts. - Images break under a sub-folder. Build with
DEMO_BASE_PATH, and always wrap public paths inasset(). - Social previews point at
clearwatertrust.example. SetNEXT_PUBLIC_SITE_URLwhen building. generateStaticParamserrors after adding[slug]routes. Static export needs every slug listed; returnSTORIES.map((s) => ({ slug: s.slug })).- A form says it was sent but nothing arrived. It's a demo until connected (see Forms).
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. The fonts are licensed under the SIL Open Font License (see src/fonts/).
Clearwater Trust and everyone in this template are fictional.