Freeframe

Documentation

Vows

v1.0.7Updated Oct 18, 2026An elegant wedding website with RSVP, schedule, travel and registry.

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.

sh
pnpm install
pnpm dev        # http://localhost:3000

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

tsx
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:

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

ts
{ 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 as asset(`/img/gallery-${p.slug}.svg`) in Gallery.tsx. Replace them with photos and keep the file names, or change the extension there. tall images are shown at 1200×1500 (4:5) and wide at 1200×900 (4:3).
  • Ornaments: wreath.svg, sprig.svg and corner.svg are decorative (alt="").
  • Social image: public/img/og-image.png (1200×630) comes from public/img/og-image.svg; edit the SVG (names, date) and export a new PNG. Its alt text is in pageMeta() in src/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="…">.

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":

  1. Find: the guest types the code or any guest's full name. findInvite() compares both ignoring case, accents, spaces and punctuation, so rose 24 and Ines Whitaker match.
  2. Details: per guest, attending yes/no and, when attending, a dinner choice and dietary needs (mealsFor() adds kidsMeal for children). Invitations with plusOne: true offer a guest with name, meal and dietary fields. Then an optional song request and note, and a required email.
  3. Review: a summary with Edit reply and Send reply.
  4. 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.

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.

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: ToastProvider in the layout; call useToast() and pass a message. Shown for 2.8 seconds in a role="status" region.
  • Reveals: RevealObserver fades in .reveal elements. The inline script in layout.tsx swaps no-js for js on <html>, so content stays visible without JavaScript; reduced motion shows everything at once.
  • Back to top: BackToTop appears after about one screen, scrolls up (instantly with reduced motion), moves focus to the main content, lifts above toasts and data-back-to-top-avoid, and hides over data-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:

json
{
  "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:

tsx
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:

ts
// 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.tsx sets metadataBase, the default title ("Amara & Theo · 19 June 2027"), a title template (%s · Amara & Theo) and the description. Each page exports metadata = pageMeta(title, description) from src/lib/meta.ts, which adds Open Graph (with og-image.png, 1200×630, and alt text) and a summary_large_image Twitter card.
  • Set NEXT_PUBLIC_SITE_URL when building so image URLs are absolute (otherwise they point at https://example.com):
sh
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's metadata, or a src/app/robots.ts that disallows everything (export dynamic = "force-static" for the static export).

Accessibility#

Built in:

  • Skip link, landmarks, one h1 per page and aria-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, and aria-current="step" in the progress list.
  • Native <dialog> lightbox, aria-pressed toggles, 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#

sh
pnpm build      # static site in out/
pnpm start      # preview it locally

next.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_URL as an environment variable.
  • Netlify, Cloudflare Pages or any static host: build command pnpm build, publish directory out.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/your/path pnpm build. It sets basePath and NEXT_PUBLIC_BASE_PATH, which asset() 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 in vows.ts (accents and case don't matter).
  • Calendar events are an hour out. Check the UTC offset on each start and end in events (+01:00 in British summer, +00:00 in winter).
  • The schedule page and the calendar file disagree. The page text is in schedule/page.tsx and the calendar data in events; update both.
  • A hydration warning mentions the no-js class. The inline script swaps it before React loads; <html> has suppressHydrationWarning for this. Keep it.
  • Images 404 in a sub-folder. Use asset() for files in public/, and build with DEMO_BASE_PATH.
  • Copy buttons only show the code. Clipboard access needs https:// or localhost.
  • Shared links show no image. Set NEXT_PUBLIC_SITE_URL to 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.

Search documentation

Search the guides and every template's documentation