Freeframe

Documentation

Nonprofit

v1.0.1Updated Oct 14, 2026A warm, trustworthy website for a charity, with a working donate page.

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.

sh
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.png

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

css
@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:

tsx
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).

ts
{
  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:

ts
{
  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 APPEAL in organisation.ts. raised and goal drive the figures, aria-valuenow and the bar; id is used in the "Double my gift" link (/donate?appeal=dry-season). Add the same id to MATCHED_APPEALS in giving.ts to show the "your gift will be doubled" note on the donate page.
  • Impact numbers: IMPACT_NUMBERS (value is a plain number; suffix is optional, e.g. "%").
  • Spending: SPENDING rows (percentages should add up to 100). The list and the donut chart are drawn from it.
  • People: LEADERSHIP and DONOR_PORTRAITS use 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> in src/app/about/page.tsx and src/app/page.tsx.
  • Stories: STORIES cards all link to /story. To give each story its own page, add a slug to Story, create src/app/stories/[slug]/page.tsx with generateStaticParams() 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).

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's AMOUNTS and, unless a custom amount is typed, selects DEFAULT_AMOUNT ($50 once, $25 monthly).
  • Amount: preset buttons or "Other amount" (minimum MIN_GIFT, $5). Typing deselects the presets.
  • Impact message: the first IMPACT row whose min is 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-expanded on 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 / YY and 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 in MATCHED_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" Email
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:

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 metadata in src/app/layout.tsx: a title template (%s — Clearwater Trust), description and Open Graph image.
  • Each page exports metadata = pageMetadata(title, description) from src/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").
  • metadataBase is process.env.NEXT_PUBLIC_SITE_URL (falling back to site.url in src/data/site.ts). Set it when you build so the social image URL is absolute:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-charity.org pnpm build

The 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 directory out.
  • 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. region must be one of the keys of REGIONS ("east-africa"), not its title.
  • A new icon name fails to type-check. icon must be a key of ICONS in src/components/Icon.tsx; add the path there first.
  • The RSVP button selects nothing. The event's id must match an option; both come from EVENTS, so check for a duplicated id.
  • The "doubled" note doesn't appear. The ?appeal= value must be listed in MATCHED_APPEALS in giving.ts.
  • Images break under a sub-folder. Build with DEMO_BASE_PATH, and always wrap public paths in asset().
  • Social previews point at clearwatertrust.example. Set NEXT_PUBLIC_SITE_URL when building.
  • generateStaticParams errors after adding [slug] routes. Static export needs every slug listed; return STORIES.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.

Search documentation

Search the guides and every template's documentation