Freeframe

Documentation

Estate

v1.0.1Updated Nov 11, 2026An upscale real-estate agency site with searchable listings and a mortgage calculator.

Overview#

Estate is a complete website for a real estate agency, shown as Harbor & Hill Realty, a fictional independent agent in the coastal town of Marlow Bay, Maine. Visitors can search and filter homes for sale or rent, save favorites, 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 Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site.

Live demo: freeframe.dev/templates/estate

Quick start#

Requirements: Node.js 24 and pnpm (corepack enable pnpm).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server at http://localhost:3000
pnpm generate Static site in .output/public/
pnpm preview Serve the last build locally
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm typecheck nuxt prepare, then vue-tsc
pnpm lint ESLint (the flat config from @nuxt/eslint)
pnpm build:demo Build for /demos/nuxt/estate into demo/ (the freeframe.dev demo; you can delete it)

Pages#

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

A listing is chosen with a query string (/property?id=3) rather than a route parameter, so one generated page serves every home and the URLs match the HTML version.

Project structure#

app/
├── app.vue                   <NuxtLayout><NuxtPage /></NuxtLayout>
├── layouts/default.vue       skip link, header, page, footer, back-to-top button and toast
├── pages/                    one file per page (index.vue, listings.vue, property.vue...)
├── error.vue                 the 404 page
├── components/
│   ├── SiteHeader.vue        logo, menu, saved-homes count, mobile menu
│   ├── SiteFooter.vue        footer links, address, newsletter form and copyright year
│   ├── PropertyCard.vue      listing card (grid and list layouts)
│   ├── HeartButton.vue       save / unsave a home
│   ├── PropertyFacts.vue     beds, baths and floor area
│   ├── PriceLabel.vue        the price, with "/mo" for rentals
│   ├── PropertyDetail.vue    everything on a property page
│   ├── PhotoLightbox.vue     full-screen photo viewer
│   ├── MortgageCalculator.vue
│   ├── ViewingForm.vue       "Book a viewing" on the property page
│   ├── ValidatedForm.vue     form validation, success message and reset
│   ├── ArtSvg.vue            renders a generated property illustration
│   ├── ArtBox.vue            a decorative illustration in a <div> (heroes, neighborhood cards)
│   ├── LogoMark.vue, ToastMessage.vue, BackToTop.vue
├── composables/
│   ├── usePageSeo.ts         title, description, canonical and Open Graph tags
│   ├── useNavStatus.ts       which of the header's Buy / Rent links is current
│   └── useAsset.ts           files in public/ with the base path
├── data/
│   ├── listings.ts           AGENTS, PROPERTIES, FEATURED, PRICE_OPTIONS, AREA_NEARBY, AREAS, TYPES
│   ├── nav.ts                header and footer links
│   └── site.ts               site name, phone number, the year the copy was written
├── utils/                    auto-imported helpers
│   ├── art.ts                SVG artwork: exteriors, interiors, floor plans
│   ├── filters.ts            listings filters: reading and writing the URL, filtering and sorting
│   ├── saved.ts              saved homes (savedIds, toggleSaved)
│   ├── safeStorage.ts        localStorage with an in-memory fallback, and the storage keys
│   ├── validate.ts           field validation messages
│   ├── toast.ts, format.ts
├── plugins/
│   ├── reveal.client.ts      scroll reveals
│   ├── saved.client.ts       reads saved homes after the page loads, follows other tabs
│   └── stable-build.server.ts  reproducible builds
├── router.options.ts         query strings written like the HTML version (?location=Old+Harbor)
└── assets/
    ├── css/main.css          Tailwind import, fonts, design tokens (@theme) and component classes
    └── fonts/                DM Sans and DM Serif Display (woff2) and their licenses
public/img/                   favicon, agent portraits (SVG), og-image.png and its og-image.svg source
nuxt.config.ts                base path, head defaults, SITE_URL, prerendered routes, Tailwind

Customizing#

Brand and colors#

The palette and fonts are tokens in the @theme block of app/assets/css/main.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 theme-color in nuxt.config.ts (the browser bar color on phones), the logo colors in app/components/LogoMark.vue and public/img/favicon.svg. The illustration palettes are in app/utils/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: the @font-face rules at the top of main.css load the files in app/assets/fonts/ (the same Latin files Google Fonts serves, under the SIL Open Font License). To change a font, replace the woff2 file and its @font-face rule, and the family name in --font-display or --font-sans. The floor plan and the contact map name DM Sans in their SVG font-family attributes (app/utils/art.ts, app/pages/contact.vue); change those too.

Content#

Listings and agents live in app/data/listings.ts; the rest of the copy is written in the page files in app/pages/.

Agents are in AGENTS (portraits in public/img/). They appear on each property page's agent card and in the listings page's "Listed by" note:

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

The agents page (app/pages/agents.vue) is plain markup: edit its cards there, and keep each "Listings" link's agent= in step with the ids.

Listings are in PROPERTIES (type Property). 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 Neighborhood; one of AREAS
type One of TYPES: "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 app/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 neighborhood.
  • AREAS and TYPES: the listings page's neighborhood and type filters.

The home search, the valuation form and the neighborhood guides on the home page list the neighborhoods in their markup (app/pages/index.vue, app/pages/sell.vue); rename or add a neighborhood there, in AREAS and in AREA_NEARBY. Each guide card's picture is <ArtBox :id="5" class="media aspect-[5/4]" />, the exterior of listing 5.

Images and artwork#

Property pictures are SVG illustrations drawn by drawing() in app/utils/art.ts from each listing's look, and rendered by ArtSvg.vue:

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

A seeded random generator makes each home always look the same, in the generated HTML and in the browser, and gradient ids are prefixed with Vue's useId() so several drawings can share a page. 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 ArtBox.vue, for example <ArtBox :id="10" time="night" class="absolute inset-y-0 right-0 -z-10" />.

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

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

with const asset = useAsset(); in the component's script. 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 and the agents page. The social image is public/img/og-image.png (1200×630, drawn from og-image.svg) and the favicon is public/img/favicon.svg.

SiteHeader.vue and SiteFooter.vue are rendered once by app/layouts/default.vue. Their links come from NAV and FOOTER_LINKS in app/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 (useNavStatus()); other items are highlighted on their own route. To add a page, create app/pages/<route>.vue, add its route to routes in nuxt.config.ts and its link to NAV.

Internal links are <NuxtLink> with :aria-current="undefined": NuxtLink otherwise marks every link to the page being shown (even /listings?status=rent while showing homes for sale) as the current page.

Features#

The search card on the home page is a plain <form method="get"> whose action is the listings page with the base path (asset('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#

app/pages/listings.vue renders the listings from PROPERTIES; the rules live in app/utils/filters.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 ("7 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 generated page shows the default "for sale" list; the URL is read in onMounted(). 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 DEFAULT_LISTING_STATE, 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 listings.vue.

Saved homes#

HeartButton.vue saves and unsaves homes with toggleSaved() from app/utils/saved.ts and shows a toast. Every component reads the same savedIds ref, so the header count ([data-saved-count]), every heart and the /listings?saved=1 view stay in sync. app/plugins/saved.client.ts fills it after the page loads and again when another tab changes it (the storage event). See Saved data.

Property page#

app/pages/property.vue reads ?id= in onMounted() and renders PropertyDetail.vue for that listing (a missing or unknown id, such as ?id=99 or ?id=constructor, shows the first listing, which is also what the generated HTML shows). It sets the tab title ("Rose Cottage — Linden Park | Harbor & Hill Realty") and meta description, 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 PhotoLightbox.vue ([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.vue ([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 is standard amortization (a 0% rate divides the loan by the number of months). Change the defaults in the ref() 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 props.p.price * 1.5 in PropertyDetail.vue.

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 (app/plugins/reveal.client.ts, which also watches content added later, such as new listing 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 nuxt.config.ts adds the js class to <html>. The plugin adds the is-in class itself, so don't put reveal on an element whose class is bound with :class.

Back to top button#

BackToTop.vue (styled by the .back-to-top rules at the end of main.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 app/layouts/default.vue and the CSS block.

The footer is written with 2026 (writtenYear in app/data/site.ts) and shows the visitor's current year once the page has loaded, so the demo never looks stale.

Forms#

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

All of them use ValidatedForm.vue, whose slot gets sent, reference and reset:

html
<ValidatedForm v-slot="{ sent, reference, reset }" class="card p-6 sm:p-8">
  <div data-form-body :hidden="sent">...fields...</div>
  <div data-success tabindex="-1" :hidden="!sent">... <strong data-ref>{{ reference }}</strong> ...</div>
</ValidatedForm>

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 app/utils/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. The viewing, valuation and contact messages show a short reference (HH- and four characters, from makeRef() in ValidatedForm.vue; show your form service's or CRM's own reference instead if it returns one). They promise a reply within one business day, as the pages already say: change both if yours differs. The footer newsletter message asks subscribers to look out for a confirmation email, so use a mailing list service that sends one (double opt-in) or reword it. 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.vue, where the comment is:

ts
async function onSubmit() {
  const el = form.value!;
  if (!formFields(el).map(validateField).every(Boolean)) {
    el.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
    return;
  }
  const res = await fetch(el.action, { method: "POST", body: new FormData(el), headers: { Accept: "application/json" } });
  if (!res.ok) return alert("Sorry, something went wrong. Please call or email us.");
  reference.value = makeRef(); // or the reference your service returns
  sent.value = true;
  el.reset();
  await nextTick();
  el.querySelector<HTMLElement>("[data-success]")?.focus();
}
html
<ValidatedForm v-slot="{ sent, reference, reset }" class="card p-6 sm:p-8" action="https://formspree.io/f/your-id">

Check that the success messages still describe what happens next. With pnpm build (a Node.js server) you can post to your own server route instead, such as server/api/viewing.post.ts.

Saved data (localStorage)#

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

readSaved() in app/utils/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. Storage goes through safeStorage (app/utils/safeStorage.ts), which keeps changes in memory for the page view when the browser blocks storage. 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#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags (og:image is /img/og-image.png) and twitter:card. The 404 page passes noindex: true.
  • Canonical and Open Graph URLs are absolute: runtimeConfig.public.siteUrl in nuxt.config.ts, from SITE_URL (falling back to https://harborandhill.example), plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-agency.com pnpm generate

Property pages are one generated 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, add a page per listing (app/pages/homes/[id].vue, with each route listed in routes in nuxt.config.ts), and a sitemap and robots.txt in public/. RealEstateListing structured data as JSON-LD (with useHead()) is a good next step.

Accessibility#

Built in:

  • a "Skip to content" link and labeled landmarks, one h1 per page;
  • visible focus styles (:focus-visible in main.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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (listings.html, linked as /listings), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /listings to listings.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/estate pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

Troubleshooting#

  • 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. The generated HTML always shows the first listing; the right one appears once the page has loaded.
  • Images or links break when deployed to a sub-folder. Build with DEMO_BASE_PATH set, and reference files in public/ with useAsset() (NuxtLink adds the base path itself).
  • Social previews use harborandhill.example. Set SITE_URL when building.
  • Saved homes disappear. They're stored per browser; private windows and cleared site data reset them.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • A Tailwind class written in a template string has no effect. Write class names in full inside quotes; Tailwind can't find classes built from pieces.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. DM Sans and DM Serif Display are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Harbor & Hill Realty, its agents and its listings are fictional.

Search documentation

Search the guides and every template's documentation