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.
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 pathCustomising#
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:
@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:
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/):
{ 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:
{ 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
<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.
Navigation, header and footer#
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#
Home-page search#
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, witharia-pressed). - Filters (
data-filters):location,type,max(price),beds,baths(minimums) andsaved. 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 inlocalStorageunderestate-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) withhistory.replaceState, so searches can be shared or bookmarked.?agent=3lists 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=1always 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.
Gallery and lightbox#
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 |
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:
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();
}<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
metadatainsrc/app/layout.tsx. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts, which also sets the Open Graph imageasset("/img/og-image.png"). metadataBaseissite.urlfromsrc/lib/site.ts:process.env.NEXT_PUBLIC_SITE_URL, falling back tohttps://example.com. Set it when you build so social images get absolute URLs:
NEXT_PUBLIC_SITE_URL=https://www.your-agency.com pnpm buildProperty 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
h1per page; - visible focus styles (
:focus-visibleinglobals.css); aria-pressedon the status, layout and heart buttons;aria-expandedon the menu and filter toggles;aria-currentin 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 arearia-hidden; - a lightbox that traps focus, closes with Esc and restores focus;
- form errors linked with
aria-describedbyandaria-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 directoryout. - 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 boundaryduring build. Components that read the query string must render inside<Suspense>, as/listingsand/propertydo.- A new listing doesn't appear. Check its
statusmatches the current mode (or choose All), that itsidis unique, and thatareaandtypeare spelled exactly as inAREASandTYPES. /propertyalways 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_PATHset, and reference public files withasset()(links made withnext/linkget the base path automatically). - Social previews use
example.com. SetNEXT_PUBLIC_SITE_URLwhen building. - Saved homes disappear. They're stored per browser; private windows and cleared site data reset them.
pnpm lintcrashes withcontextOrFilename.getFilename is not a function. Keepsettings.react.versionineslint.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.