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).
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, TailwindCustomizing#
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:
@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:
{ 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:
{ 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.AREASandTYPES: 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
<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.
Navigation, header and footer#
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#
Home-page search#
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, 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 ("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) 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 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.
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
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.
Copyright year#
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 |
All of them use ValidatedForm.vue, whose slot gets sent, reference and reset:
<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:
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();
}<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:imageis/img/og-image.png) andtwitter:card. The 404 page passesnoindex: true. - Canonical and Open Graph URLs are absolute:
runtimeConfig.public.siteUrlinnuxt.config.ts, fromSITE_URL(falling back tohttps://harborandhill.example), plus the base path. Set it when you build:
SITE_URL=https://www.your-agency.com pnpm generateProperty 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
h1per page; - visible focus styles (
:focus-visibleinmain.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 generate # static site in .output/public/
pnpm preview # serve it locallynuxt 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:
DEMO_BASE_PATH=/estate pnpm generatepnpm 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
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. 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_PATHset, and reference files inpublic/withuseAsset()(NuxtLinkadds the base path itself). - Social previews use
harborandhill.example. SetSITE_URLwhen 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
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- 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.