Freeframe

Documentation

Estate

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

Overview#

Estate is a complete website for a real-estate agency: searchable listings, property pages with a photo gallery and mortgage calculator, agent profiles and valuation requests. The demo is Harbor & Hill Realty, a fictional independent agency in the fictional coastal town of Marlow Bay, with 12 listings (for sale and for rent) and six agents. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and can be hosted anywhere. See it live at freeframe.dev/templates/estate.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

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

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/estate into demo/ (only used for the demo on freeframe.dev)

Pages#

Every file in src/pages/ becomes one page.

URL File What's on it
/ src/pages/index.astro Hero with property search, stats, six featured homes, neighbourhood guides, services, testimonials
/listings src/pages/listings.astro Buy/rent/all switch, filters, sort, grid/list view, saved homes, shareable URLs
/property?id=3 src/pages/property.astro Gallery and lightbox, key facts, features, floor plan, mortgage calculator or move-in costs, viewing form, agent card, similar homes
/agents src/pages/agents.astro Agent profiles, each linking to that agent's listings
/sell src/pages/sell.astro Process steps, fees, FAQ, free valuation form
/about src/pages/about.astro Story, values, timeline, offices
/contact src/pages/contact.astro Contact form, offices, hours, illustrated map
404 src/pages/404.astro Not-found page with links back (most hosts show 404.html automatically)

Every page also has a newsletter form in the footer.

The listings, the property page and all property artwork are filled in by the browser from the data in src/data/listings.ts, so one property page serves every listing.

Project structure#

estate/
├─ src/
│  ├─ pages/                 one .astro file per page (see above)
│  ├─ layouts/
│  │  └─ Layout.astro        <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│  ├─ components/
│  │  ├─ SiteHeader.astro    logo, navigation (Buy, Rent, Sell...), saved-homes link, mobile menu
│  │  └─ SiteFooter.astro    newsletter form, links, offices
│  ├─ data/
│  │  └─ listings.ts         agents, properties, featured homes, price filter options, area descriptions
│  ├─ lib/
│  │  ├─ site.ts             site name, theme colour and the url() helper for links
│  │  └─ format.ts           esc() and money() helpers
│  ├─ scripts/
│  │  ├─ main.ts             saved homes, menu, reveals, forms, cards, listings page, property page
│  │  ├─ art.ts              SVG artwork: exteriors, interiors, gallery views, floor plans
│  │  └─ back-to-top.ts      floating "Back to top" button
│  └─ styles/
│     └─ global.css          Tailwind theme (colours, fonts) and shared component classes
├─ public/img/               agent portraits (agent-1.svg … agent-6.svg), favicon, social image
├─ astro.config.mjs          site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.json

src/scripts/main.ts follows the parts listed in its header comment: 3. Shared UI and 4. Pages (parts 1 and 2, the data and the artwork, are the separate files above). Layout.astro imports main.ts and back-to-top.ts; Astro bundles them into one small module.

Customising#

Brand and colours#

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

Token Used for
--color-forest, --color-forest-600 Primary brand green: header accents, buttons, dark sections
--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;
}

Also update themeColor in src/lib/site.ts (#22362c), which sets <meta name="theme-color"> on every page.

Typography#

Headings use DM Serif Display (--font-display) and text uses DM Sans (--font-sans), loaded from Google Fonts by the <link rel="stylesheet"> in src/layouts/Layout.astro. To change them, replace that link and the two tokens in global.css. The floor plan's labels name DM Sans in floorPlan() in src/scripts/art.ts.

Content#

Static text (about, selling process, fees, FAQ, offices) is plain markup in src/pages/. Listings and agents are data in src/data/listings.ts.

Agents are in the AGENTS array. img is a path inside public/:

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 (agents.astro) is plain markup: update it too when you change AGENTS.

Listings are in the PROPERTIES array (typed by the Property interface). Each listing:

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 match the filter options (see below)
type "House", "Apartment", "Townhouse" or "Cottage"
price, beds, baths, sqft, year, parking, lot Facts shown on cards and the property page
listed Days since listing; "Newest" sorting uses 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 listing against Property, so pnpm check catches a missing field or a misspelt status.

Featured homes on the home page are the ids in FEATURED, in order: [1, 3, 12, 4, 5, 6].

Neighbourhoods (Old Harbor, The Docklands, Linden Park, Westshore, Cedar Heights, Hillcrest) appear in several places. If you rename or add one, update:

  • the location <select> in the home-page search (index.astro) and in the filters (listings.astro);
  • the neighbourhood guides in index.astro and any mentions in sell.astro;
  • AREA_NEARBY in src/data/listings.ts, the "puts you close to..." text on the property page.

Price filter options are in PRICE_OPTIONS (one list for buying, one for renting). The home-page search has its own max <select> with sale prices in index.astro.

Images and artwork#

Property pictures are SVG illustrations generated by src/scripts/art.ts from each listing's look:

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

Each property gets five gallery views from gallery(p): the exterior, living room, kitchen, primary bedroom and the exterior at another time of day. Static artwork on pages uses placeholders such as <div data-art="exterior" data-id="3" data-time="dusk">, filled in by hydrateArt() in main.ts.

To use photos, return images instead of SVG:

  1. Put photos in public/img/listings/ (4:3 works best for the gallery).
  2. In gallery(p) in art.ts, change each html function to return an <img> tag built with url() from src/lib/site.ts, for example html: () => `<img src="${url(`/img/listings/${p.id}-living.jpg`)}" alt="Living room">` .
  3. In card(p, view) in main.ts, replace the call to exterior(p) with the listing's cover image.

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) with its editable source public/img/og-image.svg; the favicon is public/img/favicon.svg.

  • src/components/SiteHeader.astro holds the desktop navigation and the mobile menu (#mobile-menu). Each page link gets aria-current="page" when the page's current prop matches it (current="agents" marks /agents). The Buy and Rent links point at /listings?status=buy and ?status=rent; on the listings page main.ts highlights the one that matches the current filter (data-nav-status).
  • src/components/SiteFooter.astro is the footer, including the newsletter form.

To add a page, create src/pages/careers.astro:

html
---
import Layout from "../layouts/Layout.astro";
---

<Layout title="Careers — Harbor & Hill Realty" description="Join our team." page="careers" current="careers">
  <section class="container-x py-16">
    <h1 class="h-display text-4xl">Careers</h1>
  </section>
</Layout>

Then add the link to both menus in SiteHeader.astro, written as href={url("/careers")} with aria-current={current === "careers" ? "page" : undefined}, and to the footer. Always write internal links with url() so they keep working under a base path; links built in scripts use it too (propertyUrl() in main.ts).

Features#

The search card in index.astro is a normal GET form with action={url("/listings")}. Its fields (status, location, type, max, beds) become URL parameters, which the listings page reads to pre-fill its filters.

Listings filters, sort and views#

/listings renders cards into [data-results] from PROPERTIES (initListings() in main.ts):

  • Buy / Rent / All buttons (data-status, with aria-pressed).
  • Filters form (data-filters): location, type, max (price), beds, baths (minimums) and saved. The price options switch between sale and rent ranges, and price is disabled for "All".
  • Sort: newest, price-asc, price-desc, size.
  • Grid / list view (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 updates the URL (?status=rent&location=Westshore&beds=2&sort=price-asc), so searches can be shared or bookmarked. ?agent=3 lists one agent's homes and shows a note with their name. URL values that don't match a filter option (or an agent) are ignored.
  • On small screens the filter panel collapses behind a toggle (data-filter-toggle, aria-expanded).

To add a filter, add a control with a new name to the filters form, add it to ListingsState and the state object, test it in filtered(), and add its name to the keys written to the URL in render().

Saved homes#

Heart buttons on cards and the property page save homes in localStorage (see Saved data). The header shows a count ([data-saved-count]), the "Saved homes" filter on /listings?saved=1 lists them, and changes sync between open tabs. Each change shows a toast.

Property page#

/property reads ?id= and fills the page from that listing (initProperty(); an unknown or missing id shows the first listing). It sets the document title and meta description, breadcrumbs, facts, features, a floor plan scaled to the number of bedrooms (floorPlan(p)), the agent card and three similar homes (same status, closest in price). The Share button copies the page URL and shows a toast.

The gallery shows one large view and four thumbnails; every view opens a full-screen lightbox ([data-lightbox]) with a caption ("2 / 5 · Living room"). Keyboard: Left/Right move between views, Esc closes, Tab stays inside the lightbox, and focus returns to the view you opened.

Mortgage calculator and move-in costs#

For listings with status: "buy" the page shows a mortgage calculator ([data-calc]) with the price pre-filled:

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 monthly payment uses the standard amortisation formula. Change the defaults in the inputs' value attributes in property.astro.

For rentals it shows move-in costs instead: first month plus a deposit of 1.5 × monthly rent. Change the multiplier in the line const deposit = p.price * 1.5; in main.ts.

Mobile menu#

The menu button (data-menu-toggle) opens #mobile-menu, swaps its icon, updates aria-expanded, and closes with Esc (returning focus to the button) or when the window grows to desktop width.

Scroll reveals#

Elements with reveal fade in as they scroll into view; 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 immediately. Reveals only hide content once JavaScript is running (a one-line script in the <head> adds the js class to <html>), so with JavaScript off every section is visible.

Back to top button#

src/scripts/back-to-top.ts adds a .back-to-top button that appears after you scroll down about one screen. It scrolls back up (instantly with reduced motion), moves keyboard focus to #main, and lifts itself above the toast. Add data-back-to-top-avoid to anything you pin to the bottom of the screen, or data-back-to-top-hide to a sticky element it must never cover. The styles are the .back-to-top rules in global.css. To remove it, delete the file, its import in Layout.astro and those styles.

Forms#

All forms marked data-validate share one validator (validateField() in main.ts) that uses the browser's own constraints (required, type="email", pattern, minlength, min/max) and shows friendly messages under each field. Set data-pattern-message on a field to customise its pattern message. Date inputs with data-future can't be set earlier than tomorrow on the visitor's device.

Form File Notes
Request a viewing (#viewing) src/pages/property.astro Hidden property field is filled with the listing name and reference (e.g. "Rose Cottage (HH-1003)")
Free valuation src/pages/sell.astro Property details and contact information
Contact src/pages/contact.astro General enquiries
Newsletter src/components/SiteFooter.astro Email only

On success the form's [data-form-body] is hidden and its [data-success] message shown and focused; a [data-form-reset] button brings the form back.

The forms don't send anything until you connect them. With a form service such as Formspree:

  1. Add an action to the form:

    html
    <form class="card p-6 sm:p-8" action="https://formspree.io/f/your-id" method="post" novalidate data-validate>
  2. In the submit handler in main.ts (it follows the comment "To receive submissions..."), send the data before showing the success message:

    ts
    fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } })
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        if (body) body.hidden = true;
        if (success) {
          success.hidden = false;
          success.focus();
        }
        form.reset();
      })
      .catch(() => alert("Sorry, something went wrong. Please call or email us."));

Each form can have its own endpoint. Netlify Forms also works: add data-netlify="true" and a name attribute to the form. To run your own server code instead, add an Astro adapter (for example @astrojs/node, @astrojs/netlify or @astrojs/vercel) and an API endpoint such as src/pages/api/viewing.ts with export const prerender = false, then post to it.

Saved data (localStorage)#

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

getSaved() validates estate-saved on every read: anything that isn't an array of known, unique listing ids is dropped and the stored value is repaired, so corrupt or tampered data can never break a page. estate-view falls back to grid for any unexpected value. If storage is blocked, both features still work for the current page. 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; they aren't shared between devices.

SEO and sharing#

  • Each page passes title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description and a summary_large_image Twitter card. The property page also sets its title (e.g. "Rose Cottage — Linden Park | Harbor & Hill Realty") and description from the listing when it loads, with the site name from site.name in src/lib/site.ts.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://estate.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    or change the default in astro.config.mjs. Canonical URLs have no .html (https://example.com/agents).

  • The 404 page passes noindex, which adds <meta name="robots" content="noindex"> and leaves out the canonical URL.

  • Listing pages are generated in the browser, so search engines that don't run JavaScript see generic content for /property. If listings matter for search, generate one static page per listing with a dynamic route (src/pages/homes/[id].astro with getStaticPaths() over PROPERTIES) and add @astrojs/sitemap.

Accessibility#

  • A "Skip to content" link, labelled landmarks and one <h1> per page.
  • Filter and view buttons use aria-pressed; the mobile filter panel and menu use aria-expanded.
  • Generated artwork has role="img" and descriptive labels; decorative art is aria-hidden.
  • The lightbox traps focus, closes with Esc and restores focus.
  • Form errors are linked with aria-describedby and fields get aria-invalid.
  • Reveals and smooth scrolling respect prefers-reduced-motion.

When adding listings, write meaningful title and summary text (they're used in labels). Keep one <h1> per page and label any new form field.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as listings.html rather than listings/index.html (build.format: "file" in astro.config.mjs), and links point to /listings without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /listings from listings.html automatically; Vercel does with cleanUrls (below).

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /listings serves listings.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /listings to listings.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g. DEMO_BASE_PATH=/estate pnpm build. It becomes Astro's base, and every link written with url(), including the property links and portraits the scripts add, includes it.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

Troubleshooting#

  • A new listing doesn't appear under a neighbourhood filter. Its area must exactly match an option in the location select (and type must match a type option).
  • /property?id=… shows the wrong home. The id doesn't exist in PROPERTIES; the page falls back to the first listing.
  • Saved homes disappeared. They are stored per browser; private windows and cleared site data start empty. Ids that no longer exist in PROPERTIES are removed automatically.
  • The agent card shows no photo. Check the agent's img path points at a file in public/.
  • Links or images break when served from a sub-folder. Build with DEMO_BASE_PATH, and write internal links and asset paths with url() rather than plain "/..." strings.
  • /listings shows a 404 on my host. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Forms "send" but nothing arrives. Connect them as described in Forms.

License#

MIT. The code and all included SVG artwork and portraits are free for personal and commercial use; the download includes the license text in LICENSE.txt. DM Sans and DM Serif Display are licensed under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation