Overview#
Handy is a ten-page website for a home services company: plumbing, electrical, heating and cooling, carpentry, painting and remodels. The demo brand is Brightline Home Services, a fictional company in Denver, Colorado. This is the Next.js version: Next.js 16 (App Router) with React 19, TypeScript and Tailwind CSS v4, exported as a static site. It looks and behaves like the HTML version. Highlights are an instant quote calculator that turns into a booking request, a ZIP code service-area checker with a coverage map, a before/after slider, a filterable project gallery with a lightbox, a reviews page with filters and sorting, and a sticky mobile call bar. This guide covers version 1.0.2. Live demo: freeframe.dev/templates/handy.
Quick start#
Requirements: Node.js 24 and pnpm (run corepack enable pnpm once), or npm.
pnpm install
pnpm devOpen http://localhost:3000.
| Script | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally (npx serve@latest out) |
pnpm lint |
ESLint with the Next.js config |
pnpm typecheck |
Generate route types, then run TypeScript |
pnpm build:demo |
Build for the Freeframe demo sub-folder into demo/ (safe to delete) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with ZIP checker, trust badges, services, process, reviews, projects, FAQ |
/services |
src/app/services/page.tsx |
Price tables per service (anchors #plumbing, #electrical…), Brightline Care membership |
/service |
src/app/service/page.tsx |
Water heaters in depth: what's included, pricing, before/after slider, FAQ |
/quote |
src/app/quote/page.tsx |
Quote calculator and booking request (QuoteBuilder) |
/areas |
src/app/areas/page.tsx |
Coverage map, ZIP checker and city list (AreaFinder) |
/projects |
src/app/projects/page.tsx |
Filters, project cards, lightbox (ProjectGallery) |
/reviews |
src/app/reviews/page.tsx |
Rating breakdown, service and star filters, sort (ReviewBrowser) |
/about |
src/app/about/page.tsx |
Story, values, team and licenses |
/contact |
src/app/contact/page.tsx |
Contact form, emergency line, hours |
| 404 | src/app/not-found.tsx |
Page not found |
Project structure#
handy/
├── src/
│ ├── app/
│ │ ├── layout.tsx Fonts, site metadata, skip link, shared header/footer/bars
│ │ ├── globals.css Tailwind @theme tokens and component styles
│ │ ├── icon.svg Favicon
│ │ ├── page.tsx Home page
│ │ ├── not-found.tsx 404 page
│ │ └── <route>/page.tsx One folder per page
│ ├── components/
│ │ ├── quote-builder.tsx Quote calculator and booking request form
│ │ ├── zip-checker.tsx ZIP checker and findArea()
│ │ ├── area-finder.tsx Coverage map, checker and city list
│ │ ├── before-after.tsx Before/after slider
│ │ ├── project-gallery.tsx Filters and lightbox
│ │ ├── review-browser.tsx Review filters and sort
│ │ ├── rating-summary.tsx Static rating breakdown
│ │ ├── contact-form.tsx Contact form
│ │ ├── site-header.tsx Sticky header and mobile menu
│ │ ├── site-footer.tsx Footer
│ │ ├── emergency-bar.tsx 24/7 bar above the header
│ │ ├── mobile-call-bar.tsx Sticky Call / Free quote bar on small screens
│ │ ├── back-to-top.tsx Back-to-top button
│ │ ├── reveal-observer.tsx Scroll reveals
│ │ ├── icon.tsx, logo.tsx, star-row.tsx
│ ├── data/site-data.ts Areas, navigation, services, projects, reviews, quote form choices
│ ├── lib/
│ │ ├── quote.ts Price model, estimate(), saved quote
│ │ ├── site.ts Site name and URL, asset(), pageMetadata()
│ │ └── validate.tsx useValidation() hook and FieldError
│ └── fonts/ Archivo and Public Sans (woff2) and OFL.txt
├── public/
│ ├── img/ SVG artwork
│ └── og-image.png Social sharing image
├── next.config.ts Static export and base path
├── eslint.config.mjs, postcss.config.mjs, tsconfig.json
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json Template metadata (name, version, pages, features)
├── README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
The design tokens are in the @theme block at the top of src/app/globals.css. Tailwind turns each --color-* token
into utilities such as bg-navy-900, text-sun-400 and border-navy-100.
| Token | Default | Used for |
|---|---|---|
--color-navy-50 to --color-navy-950 |
#eef4fa to #0a1d31 |
Brand navy: header, footer, headings, buttons |
--color-sun-100 to --color-sun-600 |
#fff4cc to #c98a00 |
Safety yellow: call-to-action buttons, highlights, map match |
--color-ink-600, --color-ink-700 |
#4a5a6b, #33414f |
Body text |
--color-paper |
#f7f5f0 |
Warm section backgrounds |
The yellow is meant for fills behind navy text or for text on navy, never as text on white.
@theme {
--color-navy-700: #14532d;
--color-navy-800: #0f3f22;
--color-navy-900: #0b2e19;
--color-navy-950: #071f11;
}Change the whole scale (50 to 950) so tints and hover states stay in step. Also update themeColor in the viewport
export of src/app/layout.tsx, the logo colors in src/components/logo.tsx, src/app/icon.svg, the map colors in
area-finder.tsx (the background fill="#eef4fa" and workshop marker), and accent in template.json.
Typography#
Archivo (headings, font-display) and Public Sans (body, font-sans) are self-hosted variable fonts in src/fonts/,
loaded with next/font/local in src/app/layout.tsx. Each sets a CSS variable that @theme uses:
const archivo = localFont({ src: "../fonts/Archivo-latin.woff2", weight: "100 900", variable: "--font-archivo", display: "swap", preload: false });--font-display: var(--font-archivo), "Arial Narrow", ui-sans-serif, system-ui, sans-serif;To swap a font, either drop a new .woff2 into src/fonts/ and change src, or use next/font/google:
import { Barlow_Condensed } from "next/font/google";
const archivo = Barlow_Condensed({ subsets: ["latin"], weight: ["600", "700", "800"], variable: "--font-archivo", display: "swap" });Keep the variable name, or change it in both layout.tsx and globals.css. The fonts are under the SIL Open Font
License (src/fonts/OFL.txt).
Content#
Page text is JSX in each page.tsx. Shared data lives in two files.
src/data/site-data.ts:
| Export | What it holds |
|---|---|
AREAS |
Area[]: name, zips, tier ("core" or "extended"), eta, and map polygon points, labelX, labelY |
NAV |
Header menu items (href, label) |
SERVICES, SERVICE_LABEL |
Service ids and names for the review filter and review cards |
PROJECT_FILTERS, PROJECTS |
Gallery filter buttons and projects (key, title, location, category, duration, budget) |
REVIEWS |
Reviews (name, location, service, stars, date, title, text) |
QUOTE_SERVICES |
Calculator service cards (id, name, icon, description) |
QUOTE_SIZES, QUOTE_OPTIONS, QUOTE_URGENCY |
Calculator choices and their labels |
TIME_WINDOWS |
Booking arrival windows |
src/lib/quote.ts holds the prices (see Quote calculator). src/lib/site.ts holds site.name
and site.url.
To add a service area, add an entry to AREAS; the checkers, the map and the city list all read it:
{
name: "Parker",
zips: ["80134", "80138"],
tier: "extended",
eta: "Next day",
points: "500,250 590,260 590,400 420,400 420,300",
labelX: 520,
labelY: 330,
},points are polygon coordinates in the map's 600×420 viewBox, and labelX/labelY place its name. eta is the
badge in the city list.
Contact details are written into the components and pages, not read from site. Search src/ and replace
(303) 555-0148, +13035550148, hello@brightline.example and 2150 Larimer Street, Denver, CO 80205 (they're in
emergency-bar.tsx, mobile-call-bar.tsx, site-footer.tsx, the contact page and the call buttons on most pages).
site.phone, site.phoneHref and site.email exist in src/lib/site.ts but aren't used yet; you can import them
instead of the literals.
Content that is still JSX and must be edited by hand: the price tables on /services and /service, the reviews and
projects on the home page, the team on /about, and the rating breakdown in rating-summary.tsx.
Images and artwork#
All artwork is SVG in public/img/. Files are referenced through asset() from src/lib/site.ts, which adds the base
path when the site is served from a sub-folder:
<img src={asset("/img/after.svg")} alt="After: new tankless water heater mounted on the wall" width={900} height={560} />Replace a file with a photo at the same aspect ratio and update the path and width/height:
| Files | Size | Ratio |
|---|---|---|
hero.svg |
1200×900 | 4:3 |
project-<key>.svg (8 files) |
800×600 | 4:3 |
before.svg, after.svg |
900×560 | Both must be the same size |
team-*.svg (4 files) |
240×240 | 1:1 |
van.svg |
320×160 | 2:1 |
The project gallery builds the file name from the project key (/img/project-${key}.svg), so a new project needs a
matching file, or change the src in project-gallery.tsx if you use JPGs.
public/og-image.png (1200×630) is the social image. The favicon is src/app/icon.svg, which Next.js picks up
automatically.
Navigation, header and footer#
The layout (src/app/layout.tsx) renders the skip link, EmergencyBar, SiteHeader, <main id="main">,
SiteFooter, MobileCallBar, RevealObserver and BackToTop on every page.
- Desktop menu items come from
NAVinsrc/data/site-data.ts. The mobile menu shows Home, thenNAV, then "Instant quote" (built insite-header.tsx). - The current page gets
aria-current="page"automatically fromusePathname(). - The footer link columns are written in
src/components/site-footer.tsx.
To add a page, create src/app/<route>/page.tsx with a metadata export (see SEO and sharing),
then add it to NAV and the footer.
Features#
Quote calculator#
src/components/quote-builder.tsx renders the calculator and the estimate panel. Each change updates the state,
recalculates with estimate() from src/lib/quote.ts and saves the choices (see
Saved data).
The price model in src/lib/quote.ts:
| Constant | Default |
|---|---|
PRICES |
[low, high] per service: plumbing [129, 420], electrical [119, 380], hvac [99, 650], carpentry [180, 900], painting [450, 2400], remodel [9800, 32000] |
SIZE |
small: 0.85, medium: 1, large: 1.25, xl: 1.5 |
SIZE_SCALED |
painting, remodel, hvac |
OPTIONS |
permit add [150, 250], haul add [90, 140], materials mult [1.15, 1.35], access add [80, 180] |
URGENCY |
standard, nextday add [75, 75], emergency mult [1.5, 1.5] |
How estimate() works:
- Start from
PRICES[service]. - Apply the home size factor. Services in
SIZE_SCALEDuse the full factor; others use1 + (factor - 1) * 0.3, so a large home adds 7.5% to a repair instead of 25%. - Add every fixed add-on: the selected options with an
addvalue, in the order they're listed inOPTIONS, then the urgency'sadd. - Multiply by every percentage: the selected options with a
multvalue, inOPTIONSorder, then the urgency'smult. Multipliers apply to the whole running total, add-ons included. - Round low down and high up to the nearest $10.
The result doesn't depend on the order options were ticked in, and matches the HTML version for the same choices.
| Choices | Estimate |
|---|---|
| Plumbing, 1,000–2,000 sq ft, standard | $120 – $420 |
| Plumbing, 2,000–3,000 sq ft, emergency | $200 – $680 |
| Painting, 2,000–3,000 sq ft, premium materials, haul-away, next day | $830 – $4,350 |
To change prices, edit the constants:
export const PRICES = {
plumbing: [149, 450],
// ...
} as const satisfies Record<string, readonly [number, number]>;To add a service, add its id to PRICES and SERVICE_NAMES in quote.ts (and SIZE_SCALED for whole-home work),
then a card to QUOTE_SERVICES in site-data.ts with an icon from src/components/icon.tsx (drop, bolt, fan,
hammer, roller, ruler, wrench, house…). TypeScript will flag a missing SERVICE_NAMES entry. To add an
option, add it to OPTIONS and QUOTE_OPTIONS with the same id.
Things to know:
- The display labels for the calculator come from
site-data.ts(QUOTE_*), while the breakdown lines use the labels inquote.ts. Change both when you rename something. The urgency cards' "+$75" and "+50%" are text inQUOTE_URGENCY; update them if you change the numbers. The breakdown lines themselves are built from the data byaddText()andpctText(). /quote?service=hvacpreselects a service (the services page links use this). Unknown values are ignored.- The radios, checkboxes and select are native controls, so the calculator works with the keyboard; the estimate has
aria-live="polite". - The estimate panel has
data-back-to-top-hide, so the back-to-top button hides rather than covering it.
Booking request#
The #request section of QuoteBuilder collects name, phone, email, ZIP, preferred date (min is today), arrival
windows (TIME_WINDOWS, at least one) and details. onSubmit validates, then replaces the form with a confirmation
that shows a demo reference (BL- plus four random digits from newReference()) and a summary: service, date, windows
(labels from WINDOW_NAMES at the top of the file), the estimate, whether the ZIP is in an area (findArea()) and the
phone number. Focus moves to the confirmation; "Start another request" resets the form. Nothing is sent; see
Forms.
ZIP code checker#
<ZipChecker id="…" /> from src/components/zip-checker.tsx checks for five digits and looks the ZIP up with
findArea(). Core areas get a same-day message, extended areas a next-day message, unknown ZIPs a "call us" message.
The result paragraph has role="status" and aria-live="polite". The optional onResult(area) callback is how
AreaFinder highlights the map. It's used on the home page (compact) and on /areas.
Coverage map#
src/components/area-finder.tsx draws one <polygon> per entry in AREAS inside a 600×420 SVG, with labels at
labelX/labelY. A successful check adds has-match to the map and is-match to the area, which fades the others
and outlines the match (.area-shape styles in globals.css). Core areas use area-core (navy) and extended areas
area-extended (yellow). The map is role="img" with a <title> and <desc>; the city list next to it is the text
equivalent. Update the <desc> text when you change areas.
Before/after slider#
src/components/before-after.tsx stacks after.svg and before.svg and covers them with a transparent range input.
The value sets --pos, which clips the before image (.before-after-clip in globals.css). It works with mouse,
touch and the arrow keys, shows a focus ring and sets aria-valuetext such as "60% before, 40% after". To use other
images, change the two src values and the after image's alt. The input id is fixed (ba-range), so use it once
per page.
Project gallery#
src/components/project-gallery.tsx renders PROJECT_FILTERS as toggle buttons (aria-pressed) and PROJECTS as
cards. Each project's category must match a filter id. Clicking a card opens a native <dialog> lightbox: focus
moves to the close button, Escape or a backdrop click closes it, focus returns to the card, and the arrow keys and
buttons step through the projects visible under the current filter. The count above the grid is a live region.
{
key: "roof",
title: "Cedar roof repair",
location: "Arvada · 80003",
category: "outdoor",
duration: "4 days",
budget: "$6,800",
},Add public/img/project-roof.svg (or change the image path) for it.
Reviews#
src/components/review-browser.tsx filters REVIEWS by service (SERVICES) and stars, and sorts by newest, highest
or lowest (ties sorted by date). The star filters are all, 5, 4 and 3; "3" means 3 stars or fewer. date is
YYYY-MM-DD. When nothing matches, a message with "Clear filters" appears. The count is a live region. The rating
summary (rating-summary.tsx) is static, so update its numbers and bar widths by hand.
Header, mobile menu and scroll reveals#
SiteHeader adds a shadow after 8px of scrolling. The menu button toggles aria-expanded and its label; Escape closes
the menu and returns focus to the button, and it closes on link click and at the desktop breakpoint.
RevealObserver fades in .reveal elements on each route change; when one is revealed, all earlier ones are too.
Reveals are skipped with reduced motion, and the inline script in layout.tsx adds the js class so content is only
hidden when it can be shown again.
Mobile call bar#
MobileCallBar is a fixed bar with "Call now" and "Free quote" below 1024px. It has data-back-to-top-avoid, so the
back-to-top button lifts above it. Remove <MobileCallBar /> from layout.tsx to drop it. It's hidden when printing.
Back to top#
src/components/back-to-top.tsx shows a button after about one screen of scrolling. It scrolls to the top (instantly
with reduced motion) and moves focus to <main id="main">. It lifts above toasts and anything with
data-back-to-top-avoid, hides while it would cover a fixed or sticky element with data-back-to-top-hide, and hides
while a link whose text contains "Back to top" (the footer link) is on screen. Restyle it with the .back-to-top rules
in globals.css; remove <BackToTop /> from layout.tsx to drop it.
Forms#
| Form | Component | Validation |
|---|---|---|
| ZIP checker | zip-checker.tsx |
5 digits; looks up AREAS only, nothing to send |
| Booking request | quote-builder.tsx |
Name, phone (10 digits), email, ZIP (5 digits), date (today or later), at least one window, details (20+ characters) |
| Contact | contact-form.tsx |
Name, email, optional phone (10 digits if filled), topic, message (10+ characters) |
Validation is useValidation() in src/lib/validate.tsx, with the same rules and messages as the HTML version. It
reads required, type="email", minLength and data-validate (phone, phone-optional, zip, future-date).
fieldProps(id) wires up aria-invalid, aria-describedby, blur and input checks; <FieldError> renders the
message; checkAll(form) returns the first invalid field so the submit handler can focus it.
The demo forms don't send anything. Each submit handler has a comment, "To send for real, POST new FormData(…) to your … form service here.", at the point to change.
Connecting to Formspree#
In contact-form.tsx, make onSubmit async and post before resetting:
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = e.currentTarget;
const first = checkAll(f);
if (first) {
first.focus();
return;
}
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: new FormData(f),
headers: { Accept: "application/json" },
});
if (!res.ok) {
alert("Sorry, your message could not be sent. Please call us.");
return;
}
f.reset();
setSent(true);
requestAnimationFrame(() => success.current?.focus());
}For the booking request, do the same in QuoteBuilder's onSubmit, after the validation block. The calculator is a
separate form, so append its state:
const data = new FormData(f);
data.append("service", quote.service);
data.append("size", quote.size);
data.append("options", quote.options.join(", "));
data.append("urgency", quote.urgency);
data.append("estimate", `${money(result.low)} – ${money(result.high)}`);
await fetch("https://formspree.io/f/your-form-id", { method: "POST", body: data, headers: { Accept: "application/json" } });Replace the demo reference with the ID your booking system returns, or remove it.
Using your own server#
The template uses output: "export", which has no server. To handle forms yourself, remove output: "export" from
next.config.ts (see Building and deploying) and add a Route Handler, for example
src/app/api/contact/route.ts:
export async function POST(request: Request) {
const data = await request.formData();
const email = String(data.get("email") ?? "");
if (!email.includes("@")) return Response.json({ error: "Invalid email" }, { status: 400 });
// Send an email, save to a database or forward to your CRM here.
return Response.json({ ok: true });
}Then post to /api/contact instead of the Formspree URL. Validate again on the server; browser validation can be
bypassed.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
handy-quote-v1 (QUOTE_KEY) |
{"service": "plumbing", "size": "medium", "options": [], "urgency": "standard"} |
On every calculator change (saveQuote()) |
readQuote() in src/lib/quote.ts runs after hydration and validates the value: service, size and urgency must
be keys of PRICES, SIZE and URGENCY, and options keeps only known, unique keys of OPTIONS. Anything else falls
back to DEFAULT_QUOTE. Storage errors are ignored. A ?service= in the URL overrides the saved service.
The key and shape are the same as the HTML version, so the two share a saved quote on the same origin. To reset it,
delete the key in your browser's developer tools (Application, Local Storage). If you change the shape, rename
QUOTE_KEY (for example handy-quote-v2). To stop saving, remove the saveQuote(next) call in update() in
quote-builder.tsx.
SEO and sharing#
Site-wide metadata is in src/app/layout.tsx (default title, description, openGraph, twitter). Each page exports
its own with pageMetadata(title, description) from src/lib/site.ts, which also keeps the shared social image:
export const metadata: Metadata = pageMetadata("Contact — Brightline Home Services", "Contact Brightline Home Services in Denver: …");metadataBase is new URL(site.url), and site.url is process.env.NEXT_PUBLIC_SITE_URL, falling back to
https://brightline.example. Set your domain when you build so og:image and other URLs are absolute:
NEXT_PUBLIC_SITE_URL=https://example.com pnpm buildOn Vercel, add NEXT_PUBLIC_SITE_URL as an environment variable. There is no sitemap, robots.txt or structured data.
You can add src/app/sitemap.ts and src/app/robots.ts (both work with static export), and a LocalBusiness (or
HomeAndConstructionBusiness) JSON-LD <script type="application/ld+json"> on the home page with your name, phone,
address, hours and area.
Accessibility#
Built in:
- A "Skip to content" link targeting
<main id="main">, and one<h1>per page. - Landmarks:
header, labellednavelements ("Main", "Mobile"),mainandfooter. - Visible
:focus-visibleoutlines. - A menu button with
aria-expanded,aria-controlsand a changing label; Escape closes the menu. - Live regions for ZIP results, the estimate, the gallery count and the review count.
- Form errors linked with
aria-describedbyandaria-invalid; focus moves to the first error and to confirmations. - Toggle buttons with
aria-pressedfor gallery and star filters; star ratings withrole="img"and a text label. - A native
<dialog>lightbox with focus return, and a native range input for the slider. - Reduced motion: no reveal animations, no pulsing dot, instant scrolling.
When editing, keep one <h1> per page, label every new input, keep yellow text off white, and write alt text for
meaningful photos.
Building and deploying#
pnpm buildThis writes a static site to out/ (output: "export" and images: { unoptimized: true } in next.config.ts). Preview
it with pnpm start, then upload out/ to any static host: Vercel, Netlify, Cloudflare Pages or GitHub Pages.
- Vercel: import the repository; the defaults work. Set
NEXT_PUBLIC_SITE_URL. - Sub-folder: set
DEMO_BASE_PATHwhen building, for exampleDEMO_BASE_PATH=/handy pnpm build. It setsbasePathandNEXT_PUBLIC_BASE_PATH, whichasset()uses for images. - 404: the export includes
404.html; most static hosts serve it automatically.
To use server features (Route Handlers, Server Actions, image optimization), remove output: "export" (and
images.unoptimized if you want optimized images) from next.config.ts and deploy to a Node.js host or Vercel.
pnpm start then needs to become next start.
TypeScript: tsc is TypeScript 7 (@typescript/native). Because typescript-eslint doesn't support TypeScript 7 yet,
the typescript package points at the TypeScript 6 build for ESLint and Next.js. eslint.config.mjs sets the React
version explicitly for eslint-plugin-react.
Troubleshooting#
- Images are missing when served from a sub-folder. Use
asset("/img/…")for everypublic/path, and build withDEMO_BASE_PATHset. - Social previews point to brightline.example. Set
NEXT_PUBLIC_SITE_URLat build time. - A ZIP code says it's outside the area. It must be in
AREAS[].zipsas a string, for example"80205". - TypeScript errors after adding a service. Add the id to both
PRICESandSERVICE_NAMESinquote.ts, and use an icon name that exists inicon.tsx. - The calculator starts with old choices. It restores
handy-quote-v1; clear it in developer tools.
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 under the SIL Open Font License (src/fonts/OFL.txt). Brightline
Home Services, its team and its customers are fictional.