Overview#
Vows is a wedding website for Amara & Theo, a fictional couple marrying at Hollowmere House on Saturday 19
June 2027. Guests can find their invitation by code or name and RSVP for everyone on it (meal choices, dietary
needs, plus-ones, song requests). It has a live countdown, a weekend schedule with add-to-calendar .ics files, a
travel page with an illustrated map and room blocks, a registry with reservations and a honeymoon fund, an
illustrated gallery with a lightbox and a searchable FAQ. All artwork is botanical SVG, no photos needed. This is the
Next.js 16 version (App Router, React 19, TypeScript, Tailwind CSS v4, static export), 1.0.2.
Live demo: freeframe.dev/templates/vows
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000Try the RSVP at /rsvp with a demo code such as ROSE-24, or a name such as Priya Shah.
| Script | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint |
pnpm typecheck |
next typegen then tsc --noEmit (TypeScript 7) |
pnpm build:demo |
Build for the freeframe.dev demo (base path /demos/nextjs/vows) into demo/ |
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
src/app/page.tsx |
Names and date, <Countdown />, save the date with an IcsButton, "Plan your weekend" links, story teaser, weekend overview, RSVP call to action |
/story |
src/app/story/page.tsx |
Illustrated timeline |
/schedule |
src/app/schedule/page.tsx |
Friday, Saturday and Sunday events with dress codes; a calendar file per event and for the whole weekend |
/travel |
src/app/travel/page.tsx |
Map (MapSection, MapPin, LegendButton), train/car/shuttle, room blocks with CopyButton booking codes, local tips |
/rsvp |
src/app/rsvp/page.tsx |
<Rsvp />: find the invitation, reply for each guest, review, confirmation |
/registry |
src/app/registry/page.tsx |
<HoneymoonFund /> and <Registry /> (category filter, "Hide reserved gifts", reservations) |
/gallery |
src/app/gallery/page.tsx |
<Gallery />: nine illustrations with a lightbox |
/faq |
src/app/faq/page.tsx |
FaqSearchBox, FaqStatus, FaqGroup and FaqItem |
| 404 | src/app/not-found.tsx |
Not-found page (exported as 404.html) |
Project structure#
vows/
├── next.config.ts static export, basePath from DEMO_BASE_PATH
├── package.json scripts and dependencies
├── eslint.config.mjs ESLint flat config
├── postcss.config.mjs Tailwind CSS v4 PostCSS plugin
├── public/img/
│ ├── gallery-*.svg the nine gallery illustrations
│ ├── wreath.svg, sprig.svg, corner.svg botanical ornaments
│ ├── favicon.svg initials badge
│ ├── og-image.svg source of the social image
│ └── og-image.png social sharing image, 1200×630
└── src/
├── app/
│ ├── layout.tsx fonts, metadata defaults, no-js/js switch, header, footer, toasts, reveals, back-to-top
│ ├── globals.css Tailwind import, @theme tokens and component styles
│ ├── icon.svg favicon
│ ├── page.tsx home
│ ├── not-found.tsx 404
│ └── <route>/page.tsx story, schedule, travel, rsvp, registry, gallery, faq
├── components/
│ ├── SiteHeader.tsx NAV, mobile menu (MOBILE)
│ ├── SiteFooter.tsx footer
│ ├── Countdown.tsx live countdown to couple.ceremony_iso
│ ├── IcsButton.tsx downloads .ics files
│ ├── TravelMap.tsx MapSection, MapPin, LegendButton
│ ├── CopyButton.tsx copies booking codes
│ ├── Gallery.tsx grid and <dialog> lightbox
│ ├── Faq.tsx search box, status, groups and items
│ ├── Toast.tsx ToastProvider and useToast()
│ ├── RevealObserver.tsx scroll reveals
│ ├── BackToTop.tsx back-to-top button
│ ├── Icon.tsx SVG icons
│ ├── rsvp/Rsvp.tsx the RSVP flow
│ └── registry/
│ ├── Registry.tsx gift list and reservations
│ └── HoneymoonFund.tsx fund form and progress
├── fonts/ Cormorant Garamond, Jost, Pinyon Script (Latin .woff2) and their OFL licences
├── data/vows.ts couple, events, days, meals, invites, gifts, fund, gallery, giftIcons
└── lib/
├── ics.ts buildIcs() and downloadIcs()
├── storage.ts safe localStorage wrapper
├── asset.ts asset(): prefixes public/ paths with the base path
└── meta.ts pageMeta()Customising#
Brand and colours#
Design tokens are in the @theme block of src/app/globals.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#fbf7f0 |
Page background |
--color-linen, --color-blush |
#f4ece1, #f1ddd0 |
Soft panels |
--color-ink, --color-muted, --color-line |
#2b2622, #6a5f55, #e3d8c9 |
Text, secondary text, hairlines |
--color-sage, --color-sage-deep, --color-sage-light, --color-forest |
greens | Buttons, accents, dark sections |
--color-terra, --color-terra-light |
#a4462d, #c46a4e |
Script names, focus outline, primary accents |
--color-gold |
#c9a464 |
Ornaments |
@theme {
--color-terra: #7a3e6b;
--color-sage-deep: #3e4f5a;
}The SVG ornaments and gallery illustrations have their colours inside the files. If you change --color-paper,
also change themeColor in the viewport export of src/app/layout.tsx.
Typography#
Cormorant Garamond, Jost and Pinyon Script are self-hosted with next/font/local in src/app/layout.tsx, from the
Latin .woff2 files in src/fonts/ (the same files Google Fonts serves to the HTML version, so text sets exactly like
it; their SIL Open Font License texts are the *-OFL.txt files beside them). They load as the variables
--font-cormorant, --font-jost and --font-pinyon. @theme maps them to --font-serif, --font-sans and
--font-script (the .script class used for the names). To swap one, add its .woff2 (and licence) to src/fonts/
and keep the variable, or import a family from next/font/google with the same variable:
import { Great_Vibes } from "next/font/google";
const pinyon = Great_Vibes({ variable: "--font-pinyon", subsets: ["latin"], weight: "400" });Content#
Page text is in each src/app/<route>/page.tsx; search for "Amara" to find every mention of the names. Data is in
src/data/vows.ts:
| Export | Type | Used by |
|---|---|---|
KEEP_DEMO_CURRENT |
true |
Demo only: moves every date on a year once the weekend is over (see Demo dates) |
couple |
{ a, b, names, date_long, date_short, venue, place, ceremony_iso, rsvp_by, hashtag, email } |
Countdown, header, calendar titles |
events |
VowsEvent[]: { id, day, title, start, end, place, address, dress, body } |
.ics files (IcsButton) |
days |
{ id, label, date, title } |
Not used by any page yet (reference data; the schedule is written in its page) |
meals, kidsMeal |
Meal: { id, label, note } |
RSVP dinner choices (kidsMeal is offered first to guests with child: true) |
invites |
Invite[]: { code, label, plusOne, guests: Guest[] }, Guest = { id, name, child? } |
RSVP lookup |
registryCategories |
["Kitchen", "Home", "Garden", "Experiences"] |
Registry filter buttons |
gifts |
Gift[]: { id, name, category, price, needed, reserved, icon, note } |
Gift list (icon is a key of giftIcons) |
fund |
{ title, destination, goal, raised, amounts: { value, label }[] } |
Honeymoon fund |
gallery |
Photo[]: { slug, title, caption, shape: "tall" | "wide" } |
Gallery and lightbox |
giftIcons |
Record<string, string> of SVG path markup |
Gift card icons |
The schedule page's event cards are written in src/app/schedule/page.tsx; when you change an event, update both
the page and its entry in events (which the calendar files use). Each button is
<IcsButton which="<event id>">, or which="all" for the weekend.
Add an invitation to invites. code is matched ignoring case, spaces and punctuation; guest ids must be
unique within the invitation:
{
code: "BIRCH-05",
label: "The Novak family",
plusOne: false,
guests: [
{ id: "anna", name: "Anna Novak" },
{ id: "eli", name: "Eli Novak", child: true },
],
},Add a gift to gifts (needed is how many you'd like, reserved how many are already taken). Its category
should be one of registryCategories:
{ id: "teapot", name: "Stoneware teapot", category: "Kitchen", price: 48, needed: 1, reserved: 0, icon: "pot", note: "For long Sunday breakfasts." },Times include their UTC offset (+01:00 is British Summer Time), so the countdown and calendar files are right for
guests anywhere. Use your venue's offset on the wedding date.
Demo dates
The demo is written for the weekend of 18–20 June 2027. While KEEP_DEMO_CURRENT in src/data/vows.ts is true and
that weekend is over (after the last event in events ends), every date moves to the same weekend of the next year, by
whole weeks so weekdays never change (Saturday 19 June 2027 becomes Saturday 17 June 2028): the countdown, the calendar
files, the RSVP and room-block deadlines, the schedule and the year. src/lib/edition.ts works out the shift, and
useWeddingShift() and the components in src/components/Edition.tsx (<WeddingDate>, <WeddingYear>,
<WeddingTime>, <EditionTitle>) apply it after hydration, so the static build shows the dates as written and there
is no hydration mismatch. Before the weekend is over nothing changes. Set KEEP_DEMO_CURRENT = false once you enter
your real dates.
Images and artwork#
- Gallery:
public/img/gallery-<slug>.svg, referenced asasset(`/img/gallery-${p.slug}.svg`)inGallery.tsx. Replace them with photos and keep the file names, or change the extension there.tallimages are shown at 1200×1500 (4:5) andwideat 1200×900 (4:3). - Ornaments:
wreath.svg,sprig.svgandcorner.svgare decorative (alt=""). - Social image:
public/img/og-image.png(1200×630) comes frompublic/img/og-image.svg; edit the SVG (names, date) and export a new PNG. Its alt text is inpageMeta()insrc/lib/meta.ts. - Favicon:
src/app/icon.svg. - Map: the illustrated map is SVG in
src/app/travel/page.tsx; each place is a<MapPin id="…" transform="…">with a matching<LegendButton id="…">.
Navigation, header and footer#
NAV in src/components/SiteHeader.tsx lists the desktop links; MOBILE adds Home and RSVP for the mobile menu.
The RSVP button sits beside the nav. The footer is src/components/SiteFooter.tsx. To add a page, create
src/app/<route>/page.tsx exporting metadata = pageMeta(title, description), and add it to NAV.
Features#
Countdown#
src/components/Countdown.tsx counts down to couple.ceremony_iso every second (role="timer"). The server
render shows placeholders (000, 00); the browser takes over after hydration. At zero it shows "Today's the day".
Add to calendar#
<IcsButton which="ceremony"> downloads one event, which="all" the weekend, and a toast confirms it.
buildIcs() in src/lib/ics.ts writes an RFC 5545 file from events: DTSTART/DTEND in UTC
(20270619T140000Z), backslashes, semicolons, commas and new lines escaped by escapeText() (as RFC 5545
requires), lines folded at 75 octets by fold(). SUMMARY is
"couple.names: event title", LOCATION the address and DESCRIPTION the body plus the dress code. The
PRODID, the UID domain (@amara-and-theo.example) in ics.ts and the file names in IcsButton.tsx
(amara-and-theo-wedding-weekend.ics, amara-and-theo-<id>.ics) are written out; change them to your names.
RSVP#
src/components/rsvp/Rsvp.tsx, in four steps (Step: find, details, review, done) shown in a progress
list with aria-current="step":
- Find: the guest types the code or any guest's full name.
findInvite()compares both ignoring case, accents, spaces and punctuation, sorose 24andInes Whitakermatch. - Details: per guest, attending yes/no and, when attending, a dinner choice and dietary needs (
mealsFor()addskidsMealfor children). Invitations withplusOne: trueoffer a guest with name, meal and dietary fields. Then an optional song request and note, and a required email. - Review: a summary with Edit reply and Send reply.
- Done: a thank-you, and an "Add the weekend" calendar button if anyone is coming.
validate() requires an answer for every guest, a meal for every attending guest, a name and meal for a plus-one,
and a valid email (EMAIL). Problems are listed in a summary and on each field, and focus moves to the first one;
after that, errors update as the guest edits. Text is capped by LIMITS (dietary 300, name 80, song 120, note 500,
email 254). Every change is saved as a draft (see Saved data) and restored, with a
notice, if the guest comes back.
Privacy: invites is bundled into the page's JavaScript, so the whole guest list is public. For a real wedding,
look invitations up on a server (see Forms).
Registry#
src/components/registry/Registry.tsx shows gifts with category buttons (registryCategories, aria-pressed),
a "Hide reserved gifts" checkbox and a live count. Reserve marks a gift as reserved by this visitor ("Reserved
by you", with Undo); when reserved plus the visitor's reservation reaches needed, it's fully reserved.
Reservations are kept only in the visitor's browser, so other guests don't see them; for a shared list, store them on
a server or link each gift to your gift-list provider.
Honeymoon fund#
src/components/registry/HoneymoonFund.tsx. Guests choose one of fund.amounts or type a whole amount from £5 to
£5,000, add their name (required) and an optional message. On success the raised total and progress bar update for
this visit and a thank-you names the matching treat. No payment is taken; see the // Demo only comment in the
submit handler.
Travel map#
src/components/TravelMap.tsx. MapSection holds the selected place; LegendButton (aria-pressed) selects or
clears it and is the keyboard-accessible control; MapPin highlights (is-active) and can be clicked too.
Booking codes#
<CopyButton code="AMTHEO27"> copies the code and confirms with a toast; where copying isn't allowed, the toast
shows the code instead.
Gallery lightbox#
src/components/Gallery.tsx: each photo button opens a native <dialog> with showModal() (focus trapped,
Escape closes, focus starts on Close). It shows the image, title, caption and "N of 9"; Left/Right arrows and the
Previous/Next buttons wrap round gallery, clicking the backdrop closes it, and focus returns to the photo.
FAQ search#
src/components/Faq.tsx. FaqSearchBox (in the page header) and the groups share the query. Matching questions
open, others and empty groups hide, and FaqStatus (a live region) reports the count; clearing the search restores
what was open. Wrap each question in <FaqItem> inside a <FaqGroup> on src/app/faq/page.tsx.
Toasts, reveals and back to top#
- Toasts:
ToastProviderin the layout; calluseToast()and pass a message. Shown for 2.8 seconds in arole="status"region. - Reveals:
RevealObserverfades in.revealelements. The inline script inlayout.tsxswapsno-jsforjson<html>, so content stays visible without JavaScript; reduced motion shows everything at once. - Back to top:
BackToTopappears after about one screen, scrolls up (instantly with reduced motion), moves focus to the main content, lifts above toasts anddata-back-to-top-avoid, and hides overdata-back-to-top-hide.
Forms#
None of the forms send anything until you connect them.
| Form | File | Where to connect it |
|---|---|---|
| RSVP | src/components/rsvp/Rsvp.tsx |
send() (the comment starting // Demo only) |
| Honeymoon fund | src/components/registry/HoneymoonFund.tsx |
The submit handler (comment // Demo only: send the visitor to your payment page…) |
| Gift reservations | src/components/registry/Registry.tsx |
The reserve/undo handler |
The RSVP sends reply, typed as Reply:
{
"code": "ROSE-24",
"answers": { "priya": { "attending": "yes", "meal": "hake", "dietary": "" }, "daniel": { "attending": "no", "meal": "", "dietary": "" } },
"plusOne": { "bringing": false, "name": "", "meal": "", "dietary": "" },
"song": "September",
"note": "",
"email": "priya@example.com"
}With Formspree, make send post it and only continue when it succeeds:
const send = async () => {
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(reply),
});
if (!res.ok) return toast("Sorry, your reply could not be sent. Please try again.");
storage.remove(DRAFT_KEY);
goTo("done");
};(toast comes from useToast() in ../Toast.) Update the confirmation text, which says it's a demo.
Server option: remove static export (see Building and deploying) and add a Route Handler, which also lets you keep the guest list private:
// src/app/api/rsvp/route.ts
export async function POST(request: Request) {
const reply = await request.json();
// check reply.code against your guest list, then save the reply (database, Google Sheet, email)
return Response.json({ ok: true });
}Post to /api/rsvp from send(). Add a second route (for example GET /api/invite?q=…) that returns one
invitation, call it from onFind instead of findInvite(), and remove invites from src/data/vows.ts. A Server
Action works too.
Saved data (localStorage)#
Every read and write goes through storage in src/lib/storage.ts: blocked storage fails silently, and a value
that isn't valid JSON is deleted.
| Key | Shape | Validation on read |
|---|---|---|
vows:rsvp-draft:v1 (DRAFT_KEY in Rsvp.tsx) |
Reply (above) |
readDraft(): must be an object whose code is a known invitation (otherwise removed) and matches the invitation just found. Answers are kept only for that invitation's guests, attending must be yes or no, meals must be allowed for that guest, plus-one data only if the invitation allows one, and every text is cut to LIMITS. The cleaned draft is saved back. Removed when the reply is sent. |
vows:reserved:v1 (KEY in Registry.tsx) |
Array of gift ids | readReserved(): must be an array; only known gift ids with room left (needed - reserved >= 1), without duplicates. Saved back if anything was dropped. |
To reset, delete the keys in the browser's developer tools (Application → Local Storage). Change the :v1 suffix if
you change the data shape, so old values are ignored.
SEO and sharing#
src/app/layout.tsxsetsmetadataBase, the default title ("Amara & Theo · 19 June 2027"), a title template (%s · Amara & Theo) and the description. Each page exportsmetadata = pageMeta(title, description)fromsrc/lib/meta.ts, which adds Open Graph (withog-image.png, 1200×630, and alt text) and asummary_large_imageTwitter card.- Set
NEXT_PUBLIC_SITE_URLwhen building so image URLs are absolute (otherwise they point athttps://example.com):
NEXT_PUBLIC_SITE_URL=https://your-domain.com pnpm build- A wedding site usually shouldn't be indexed. Add
robots: { index: false, follow: false }to the layout'smetadata, or asrc/app/robots.tsthat disallows everything (exportdynamic = "force-static"for the static export).
Accessibility#
Built in:
- Skip link, landmarks, one
h1per page andaria-current="page"in the navigation. - Visible focus outlines (terracotta) and AA contrast.
- Mobile menu with
aria-expanded; Escape closes it and returns focus, and it closes after navigating. - RSVP: labelled radio groups, an error summary, errors linked with
aria-describedby, focus moved to each step's heading or the first problem, andaria-current="step"in the progress list. - Native
<dialog>lightbox,aria-pressedtoggles,role="timer"countdown and polite live regions for counts and toasts. - Reduced motion turns off reveals and smooth scrolling.
When editing, keep one h1 per page, label every new field, and give new gallery images a meaningful caption (it's
used as the alt text).
Building and deploying#
pnpm build # static site in out/
pnpm start # preview it locallynext.config.ts sets output: "export" (plain HTML, CSS and JS in out/) and images: { unoptimized: true }.
- Vercel: import the repository; no settings needed. Add
NEXT_PUBLIC_SITE_URLas an environment variable. - Netlify, Cloudflare Pages or any static host: build command
pnpm build, publish directoryout. - Sub-folder hosting: build with
DEMO_BASE_PATH=/your/path pnpm build. It setsbasePathandNEXT_PUBLIC_BASE_PATH, whichasset()adds to image paths.
Need server features (an RSVP Route Handler, a private guest list, payments)? Remove output: "export" and
images: { unoptimized: true } from next.config.ts and deploy to Vercel or a Node.js host (next start).
TypeScript setup: package.json installs @typescript/native (TypeScript 7, which provides tsc for
pnpm typecheck) and typescript as an alias of @typescript/typescript6, the TypeScript 6 API that
typescript-eslint and parts of the Next.js toolchain still need. When typescript-eslint supports TypeScript 7,
replace both packages with a single typescript@7.
Troubleshooting#
- "We couldn't find that invitation." The code or full name doesn't match an entry in
invites; names must be the full name as written invows.ts(accents and case don't matter). - Calendar events are an hour out. Check the UTC offset on each
startandendinevents(+01:00in British summer,+00:00in winter). - The schedule page and the calendar file disagree. The page text is in
schedule/page.tsxand the calendar data inevents; update both. - A hydration warning mentions the
no-jsclass. The inline script swaps it before React loads;<html>hassuppressHydrationWarningfor this. Keep it. - Images 404 in a sub-folder. Use
asset()for files inpublic/, and build withDEMO_BASE_PATH. - Copy buttons only show the code. Clipboard access needs
https://orlocalhost. - Shared links show no image. Set
NEXT_PUBLIC_SITE_URLto your domain when building.
License#
MIT, like every Freeframe template: free for personal and commercial projects, no attribution required. The
botanical illustrations and social image are included under the same license. See LICENSE.txt in the download.
Names, places and people are fictional.