Freeframe

Documentation

Estate

v1.0.2Updated Oct 16, 2026An upscale real-estate agency site with searchable listings and a mortgage calculator.

Overview#

Estate is a complete website for an estate agency, shown as Harbor & Hill Realty, a fictional independent agent in the coastal town of Marlow Bay. Visitors can search and filter homes for sale or rent, save favourites, open a property page with a photo gallery and full-screen lightbox, work out monthly payments, book a viewing, request a valuation and meet the team. All twelve listings live in one typed data file and every property picture is a generated SVG illustration, so swapping in your own homes is quick. 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/estate

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/estate into demo/ (the freeframe.dev demo; you can delete it)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Hero, home search, track record, six featured homes, neighbourhood guides, services, founder quote, reviews, valuation banner
/listings src/app/listings/page.tsx Buy / Rent / All, filters, sort, grid or list layout, saved homes, all kept in the URL
/property?id=<id> src/app/property/page.tsx One listing: gallery and lightbox, facts, features, floor plan, mortgage calculator or move-in costs, agent card, viewing form, similar homes
/agents src/app/agents/page.tsx Six agent cards with a link to each agent's listings, careers banner
/sell src/app/sell/page.tsx Selling steps, fees, free valuation form, FAQ
/about src/app/about/page.tsx Story, values, figures, timeline and offices
/contact src/app/contact/page.tsx Contact form, illustrated map, offices and opening hours
404 src/app/not-found.tsx Page not found

A listing is chosen with a query string (/property?id=3) rather than a dynamic segment, so the whole site can be a static export.

Project structure#

src/
├── app/
│   ├── layout.tsx            fonts, site metadata, header, footer, toast, reveal observer, back-to-top
│   ├── page.tsx              home
│   ├── <route>/page.tsx      one folder per page
│   ├── not-found.tsx         404 page
│   ├── globals.css           Tailwind import, design tokens (@theme) and component classes
│   └── icon.svg              favicon
├── components/
│   ├── SiteHeader.tsx        logo, menu, saved-homes count, mobile menu
│   ├── SiteFooter.tsx        footer links, address and newsletter form
│   ├── PropertyCard.tsx      listing card (grid and list layouts)
│   ├── HeartButton.tsx       save / unsave a home
│   ├── PropertyFacts.tsx     beds, baths and floor area; price with "/mo" for rentals
│   ├── ListingsBrowser.tsx   the listings page (filters, sort, layout, URL)
│   ├── PropertyDetail.tsx    the property page
│   ├── Lightbox.tsx          full-screen photo viewer
│   ├── MortgageCalculator.tsx
│   ├── ViewingForm.tsx       "Book a viewing" on the property page
│   ├── ValidatedForm.tsx     form validation, success message and reset
│   ├── Art.tsx               renders the generated property illustrations
│   ├── Icons.tsx, Logo.tsx   inline SVG icons and the logo mark
│   ├── Toast.tsx, BackToTop.tsx, RevealObserver.tsx, Year.tsx
├── data/
│   ├── listings.ts           AGENTS, PROPERTIES, FEATURED, PRICE_OPTIONS, AREA_NEARBY
│   ├── content.ts            neighbourhoods, services, reviews, team bios, selling steps, fees, FAQ, timeline...
│   └── nav.ts                header and footer links
├── fonts/                    DM Sans and DM Serif Display (woff2) and their licence
└── lib/
    ├── art.ts                SVG artwork: exteriors, interiors, floor plans
    ├── listings.ts           listings filters: reading and writing the URL, filtering and sorting
    ├── saved.ts              saved homes in localStorage
    ├── validate.ts           field validation messages
    ├── metadata.ts           pageMetadata() helper
    ├── site.ts               site name, URL, theme colour, phone number
    ├── asset.ts, format.ts, nav-status.ts, reveal.ts, toast.ts
public/img/                   agent portraits (SVG) and og-image.png
next.config.ts                static export and base path

Customising#

Brand and colours#

The palette and fonts are tokens in the @theme block at the top of src/app/globals.css:

Token Used for
--color-forest, --color-forest-600 Primary brand green: buttons, dark sections, prices
--color-brass, --color-brass-600, --color-brass-700, --color-brass-soft Accent: badges, icons, focus rings, highlights
--color-cream, --color-sand, --color-paper Page and card backgrounds
--color-ink, --color-muted Text
--color-line Borders

For example, a navy brand:

css
@theme {
  --color-forest: #1f2f4a;
  --color-forest-600: #2b3f61;
}

Every utility (bg-forest, text-brass-600...) and component class (.btn-primary, .badge, .heart, .seg...) follows. Also update themeColor in src/lib/site.ts (the browser bar colour on phones) and the logo colours in src/components/Logo.tsx. The illustration palettes are in src/lib/art.ts and each listing's look.

Typography#

Headings use DM Serif Display (--font-display, the h-display class) and text uses DM Sans (--font-sans). Both are self-hosted with next/font/local in src/app/layout.tsx from the files in src/fonts/ (the same Latin files Google Fonts serves, under the SIL Open Font License in src/fonts/OFL.txt). To change them, swap the files and the localFont() calls, or use next/font/google:

tsx
import { Fraunces } from "next/font/google";
const display = Fraunces({ subsets: ["latin"], variable: "--font-dm-serif" });

Keeping the variable names (--font-dm-sans, --font-dm-serif) means globals.css needs no change. The floor plan and the contact map draw their labels with var(--font-sans), so they follow too.

Content#

Page copy that comes in lists lives in typed files under src/data/; one-off copy is in the page files.

Agents are in AGENTS in src/data/listings.ts (portraits in public/img/):

ts
{ id: 1, name: "Eleanor Vance", role: "Founding Partner", phone: "(555) 014-2280", email: "eleanor@example.com", img: "/img/agent-1.svg" },

The agents page adds a bio, areas and tags for each from TEAM in src/data/content.ts.

Listings are in PROPERTIES (type Property) in src/data/listings.ts. To add a home, append an object:

ts
{ id: 13, title: "Garden Flat on Mill Road", address: "22 Mill Road", area: "Linden Park", status: "rent", type: "Apartment",
  price: 2400, beds: 2, baths: 1, sqft: 900, year: 1998, parking: "Street", lot: "—", listed: 1, agent: 3, badges: ["New"],
  look: { kind: "townhouse", time: "day", wall: "#ece4d6", trim: "#3b3a36", accent: "#7d8f7a", room: "#efe8dc" },
  summary: "A bright two-bedroom garden flat a short walk from the park.",
  features: ["Private garden", "Bike storage", "Pets considered"] },
Field Meaning
id Unique number, used in /property?id=
status "buy" or "rent" (rent prices are per month)
area Neighbourhood; must be one of AREAS in src/data/content.ts
type One of TYPES in src/data/content.ts: "House", "Apartment", "Townhouse", "Cottage"
price, beds, baths, sqft, year, parking, lot Facts shown on cards and the property page
listed Days since listing; "Newest first" sorts by it
agent An id from AGENTS
badges Short labels such as "New" or "Open house Sat"
look Illustration settings (see Images and artwork)
summary, features Description and bullet list on the property page

TypeScript checks every field, so a typo shows up in pnpm typecheck.

Other data in src/data/listings.ts:

  • FEATURED: the ids shown under "New to the market" on the home page, in order ([1, 3, 12, 4, 5, 6]).
  • PRICE_OPTIONS: the listings page's max-price choices, one list for buying and one for renting.
  • AREA_NEARBY: the "... puts you close to ..." sentence on the property page, per neighbourhood.

Neighbourhoods come from AREAS (the home search, listings filter and valuation form) and NEIGHBOURHOODS (the home page guides, each with a property id whose exterior is its picture), both in src/data/content.ts. Rename or add one in both, and in AREA_NEARBY.

src/data/content.ts also holds TRACK_RECORD, SERVICES, REVIEWS, SELL_STEPS, FEES, SELL_FAQ, VALUES, ABOUT_NUMBERS, TIMELINE and OFFICES. Edit the arrays and the pages follow. The home search's own max-price list is MAX_PRICES in src/app/page.tsx.

Images and artwork#

Property pictures are SVG illustrations drawn by drawing() in src/lib/art.ts from each listing's look, and rendered by <ArtSvg> and <Art> in src/components/Art.tsx:

look field Values
kind modern, townhouse, cottage, apartment, coastal (exterior style)
time day, golden, dusk, night (sky and lighting)
wall, trim, accent, room Hex colours for walls, window frames, details and interior walls

A seeded random generator makes each home always look the same, on the server and in the browser. gallery(p) lists five views (exterior, living room, kitchen, primary bedroom and the exterior at another time of day), and floorPlan(p) draws a plan scaled to the number of bedrooms. Decorative art on pages is placed with, for example, <Art className="media aspect-[5/4]" id={5} a11y="hidden" />.

To use photos, render an image where <ArtSvg> is used: in PropertyCard.tsx (the card picture), PropertyDetail.tsx (gallery buttons) and Lightbox.tsx (the large view), for example

tsx
<img src={asset(`/img/listings/${p.id}-${v.kind}.jpg`)} alt={v.label} className="h-full w-full object-cover" />

Use 4:3 photos for cards and the gallery and 3:2 for the lightbox (both crop to fill). Agent portraits are public/img/agent-*.svg (600×700); replace them with photos of the same proportions and update img in AGENTS. The social image is public/img/og-image.png (1200×630) and the favicon is src/app/icon.svg.

SiteHeader.tsx and SiteFooter.tsx are rendered once by src/app/layout.tsx. Their links come from NAV and FOOTER_LINKS in src/data/nav.ts. A NAV item with status: "buy" or "rent" is highlighted (aria-current) only on the listings page while it shows that status; other items are highlighted on their own route. To add a page, create src/app/<route>/page.tsx and add its link to NAV.

Features#

The search card on the home page is a next/form <Form action="/listings">: submitting it opens /listings?status=rent&location=... with its fields (status, location, type, max, beds) as URL parameters, which the listings page reads. Empty fields are dropped from the URL there.

Listings filters, sort and views#

ListingsBrowser.tsx renders the listings page from PROPERTIES; the rules live in src/lib/listings.ts:

  • Buy / Rent / All buttons (data-status, with aria-pressed).
  • Filters (data-filters): location, type, max (price), beds, baths (minimums) and saved. The price choices switch between sale and rent ranges, and price is disabled for "All".
  • Sort: newest, price-asc, price-desc, size.
  • Grid / list layout (data-view), remembered in localStorage under estate-view.
  • A live count ("8 homes for sale"), a title that follows the mode, an empty state and Clear filters (data-reset).
  • Every change rewrites the URL (?status=rent&location=Westshore&beds=2&sort=price-asc) with history.replaceState, so searches can be shared or bookmarked. ?agent=3 lists one agent's homes with a note. parseListingState() ignores values that aren't one of a filter's options (?type=banana), unknown agents and prices that don't belong to the status; ?saved=1 always shows all statuses.
  • On small screens the filter panel collapses behind a toggle (data-filter-toggle, aria-expanded).

The page reads the URL with useSearchParams() inside <Suspense>, so the exported HTML shows the default "for sale" list until the browser has the query string. Following another listings link while on the page (the header's Rent link, say) applies its filters.

To add a filter: add a field to ListingState and read it in parseListingState(), test it in filterListings(), add its key to the list in listingSearch(), and add the control to the filters form in ListingsBrowser.tsx.

Saved homes#

HeartButton.tsx saves and unsaves homes with toggleSaved() from src/lib/saved.ts and shows a toast. Components read the list with the useSaved() hook, so the header count ([data-saved-count]), every heart and the /listings?saved=1 view stay in sync, including across open tabs (the storage event). See Saved data.

Property page#

PropertyDetail.tsx shows the listing for ?id= (propertyFor(): a missing or unknown id, such as ?id=99 or ?id=constructor, shows the first listing). It sets the tab title ("Rose Cottage — Linden Park | Harbor & Hill Realty") and meta description in the browser, and renders breadcrumbs, facts, features, the floor plan, the agent card and three similar homes (same status, closest in price). The share button (data-share) copies the page URL and shows a toast.

The gallery shows one large view and four smaller ones (one on phones, with a "View all 5 photos" button); each opens Lightbox.tsx ([data-lightbox], role="dialog") with a caption such as "2 / 5 · Living room". Left/Right browse (wrapping around), Esc or a click on the backdrop closes, Tab stays inside, page scrolling is locked while it's open, and focus returns to the view you opened.

Mortgage calculator and move-in costs#

For listings with status: "buy", MortgageCalculator.tsx ([data-calc]) shows a calculator with the price filled in:

Input Default
Price (price) The listing price
Deposit (deposit, range) 20% (5–50%)
Interest rate (rate) 6.25%
Term (term) 30 years (15, 20, 25 or 30)

It updates as you type and shows the deposit, monthly payment, loan amount, total interest, total cost and a principal/interest bar. The payment comes from mortgage() (standard amortisation; a 0% rate divides the loan by the number of months). Change the defaults in the useState() calls at the top of the component.

For rentals the page shows move-in costs instead ([data-movein]): the first month plus a deposit of 1.5 months' rent. Change the multiplier in const deposit = p.price * 1.5; in PropertyDetail.tsx.

Mobile menu#

The menu button (data-menu-toggle) shows #mobile-menu, updates aria-expanded and its label, and closes with Esc (focus returns to the button), when a link is chosen, or when the window grows to desktop width.

Scroll reveals#

Elements with the reveal class fade in as they scroll into view (src/lib/reveal.ts, started on each page by RevealObserver.tsx; the listings page re-runs it for new cards). Reaching one also reveals everything above it, so jumping to the end of a page never leaves hidden content. With reduced motion everything is shown at once. Content is only hidden while JavaScript runs: a one-line script in layout.tsx adds the js class to <html>.

Back to top button#

BackToTop.tsx (styled by the .back-to-top rules at the end of globals.css) appears after about one screen of scrolling, returns to the top (instantly with reduced motion) and moves focus to <main>. It lifts above the toast, and hides while it would cover the property page's sticky agent sidebar (data-back-to-top-hide). Mark your own pinned elements with data-back-to-top-avoid or data-back-to-top-hide. To remove it, delete <BackToTop /> from layout.tsx and the CSS block.

Forms#

Form Where Validates
Book a viewing (#viewing) ViewingForm.tsx (property page) Name, email, optional phone, date from tomorrow, time
Free valuation src/app/sell/page.tsx Address, area, type, bedrooms (0–12), optional size (100–20,000), condition, timeline, name, email, phone, consent
Contact src/app/contact/page.tsx Name, email, optional phone, topic, message of at least 10 characters
Newsletter SiteFooter.tsx Email

All of them use ValidatedForm from ValidatedForm.tsx with <FormBody>, <FormSuccess> and <FormReset>. Fields are checked with the browser's own constraints (required, type="email", pattern, minLength, min/max) when they lose focus and on submit. Messages come from fieldMessage() in src/lib/validate.ts; each appears in a <p class="field-error"> inside the field's data-field wrapper, linked with aria-describedby, and focus moves to the first invalid field. Phone fields use PHONE_PATTERN, and a field's data-pattern-message sets its pattern message. Date inputs with data-future get a min of tomorrow on the visitor's calendar. The viewing form's hidden property field holds the listing name and reference, e.g. "Rose Cottage (HH-1003)".

On success the fields are hidden and the success message shown and focused; the reset button brings the fields back. Nothing is sent until you connect the forms.

To post to a form service such as Formspree, give the form an action and send the data in onSubmit() in ValidatedForm.tsx, where the comment is:

tsx
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const form = e.currentTarget;
  if (!formFields(form).map(validateField).every(Boolean)) {
    form.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
    return;
  }
  const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } });
  if (!res.ok) return alert("Sorry, something went wrong. Please call or email us.");
  setSent(true);
  form.reset();
}
tsx
<ValidatedForm className="card p-6 sm:p-8" action="https://formspree.io/f/your-id">

Update the success messages, which currently say the form is a demo. If you remove the static export (see Building and deploying), you can post to your own Route Handler instead (for example src/app/api/viewing/route.ts exporting POST) or pass a Server Action as the form's action.

Saved data (localStorage)#

Key Shape Purpose
estate-saved (SAVED_KEY) Array of listing ids, e.g. [3, 7] Saved homes
estate-view "grid" or "list" Preferred listings layout

getSaved() in src/lib/saved.ts validates estate-saved on every read: invalid JSON, non-arrays, unknown ids, non-numbers and duplicates are dropped and the stored value is repaired, so tampered data can't break a page. estate-view falls back to grid for any other value. To reset, clear the site's storage in the browser's developer tools, or run localStorage.removeItem("estate-saved"). Saved homes stay on one device and browser.

SEO and sharing#

  • Site-wide defaults (title, description, Open Graph) are in metadata in src/app/layout.tsx.
  • Each page exports metadata = pageMetadata(title, description) from src/lib/metadata.ts, which also sets the Open Graph image asset("/img/og-image.png").
  • metadataBase is site.url from src/lib/site.ts: process.env.NEXT_PUBLIC_SITE_URL, falling back to https://example.com. Set it when you build so social images get absolute URLs:
sh
NEXT_PUBLIC_SITE_URL=https://www.your-agency.com pnpm build

Property pages are one static page filled in the browser, so search engines and social previews see the generic "Property details" title and the first listing. If listings matter for search, switch to a dynamic route src/app/homes/[id]/page.tsx with generateStaticParams() (one exported page per listing, with its own generateMetadata()), and add src/app/sitemap.ts and src/app/robots.ts (both work with static export). RealEstateListing structured data as JSON-LD is a good next step.

Accessibility#

Built in:

  • a "Skip to content" link and labelled landmarks, one h1 per page;
  • visible focus styles (:focus-visible in globals.css);
  • aria-pressed on the status, layout and heart buttons; aria-expanded on the menu and filter toggles; aria-current in the menus and breadcrumbs;
  • live regions for the listings count, the monthly payment, the lightbox caption and toasts;
  • generated artwork with role="img" and a label; decorative art and duplicate card pictures are aria-hidden;
  • a lightbox that traps focus, closes with Esc and restores focus;
  • form errors linked with aria-describedby and aria-invalid, with focus moved to the first error;
  • reveals, smooth scrolling, hover zooms and the back-to-top button respect reduced motion.

When editing, keep one h1 per page, label every new input, and write meaningful listing titles (they're used in labels such as "Save Rose Cottage to saved homes").

Building and deploying#

pnpm build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:

  • 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 the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/estate pnpm build. next.config.ts passes it to basePath and to NEXT_PUBLIC_BASE_PATH, which asset() uses for files in public/. pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.

Need server features (a Route Handler for enquiries, Server Actions, per-listing pages rendered on demand)? Remove output: "export" and the images.unoptimized setting from next.config.ts and deploy to a Node-capable host such as Vercel. Everything else keeps working.

TypeScript setup#

package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6 compiler API. Once typescript-eslint supports TypeScript 7, replace both with a single typescript@7. eslint.config.mjs also pins settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.

Troubleshooting#

  • useSearchParams() should be wrapped in a suspense boundary during build. Components that read the query string must render inside <Suspense>, as /listings and /property do.
  • A new listing doesn't appear. Check its status matches the current mode (or choose All), that its id is unique, and that area and type are spelled exactly as in AREAS and TYPES.
  • /property always shows the same home. The URL needs ?id= with an existing listing id.
  • Images or links break when deployed to a sub-folder. Build with DEMO_BASE_PATH set, and reference public files with asset() (links made with next/link get the base path automatically).
  • Social previews use example.com. Set NEXT_PUBLIC_SITE_URL when building.
  • Saved homes disappear. They're stored per browser; private windows and cleared site data reset them.
  • pnpm lint crashes with contextOrFilename.getFilename is not a function. Keep settings.react.version in eslint.config.mjs.

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 generated property artwork and the agent portraits. DM Sans and DM Serif Display are under the SIL Open Font License (src/fonts/OFL.txt). Harbor & Hill Realty, its agents and its listings are fictional.

Search documentation

Search the guides and every template's documentation