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).
pnpm install
pnpm dev # http://localhost:4321With 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.jsonsrc/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:
@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/:
{ 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:
{ 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.astroand any mentions insell.astro; AREA_NEARBYinsrc/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:
- Put photos in
public/img/listings/(4:3 works best for the gallery). - In
gallery(p)inart.ts, change eachhtmlfunction to return an<img>tag built withurl()fromsrc/lib/site.ts, for examplehtml: () => `<img src="${url(`/img/listings/${p.id}-living.jpg`)}" alt="Living room">`. - In
card(p, view)inmain.ts, replace the call toexterior(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.
Navigation, header and footer#
src/components/SiteHeader.astroholds the desktop navigation and the mobile menu (#mobile-menu). Each page link getsaria-current="page"when the page'scurrentprop matches it (current="agents"marks/agents). The Buy and Rent links point at/listings?status=buyand?status=rent; on the listings pagemain.tshighlights the one that matches the current filter (data-nav-status).src/components/SiteFooter.astrois the footer, including the newsletter form.
To add a page, create src/pages/careers.astro:
---
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#
Home-page search#
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, witharia-pressed). - Filters form (
data-filters):location,type,max(price),beds,baths(minimums) andsaved. 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 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 updates the URL (
?status=rent&location=Westshore&beds=2&sort=price-asc), so searches can be shared or bookmarked.?agent=3lists 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.
Gallery and lightbox#
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:
-
Add an
actionto the form:html<form class="card p-6 sm:p-8" action="https://formspree.io/f/your-id" method="post" novalidate data-validate> -
In the submit handler in
main.ts(it follows the comment "To receive submissions..."), send the data before showing the success message:tsfetch(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
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:descriptionand asummary_large_imageTwitter 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 fromsite.nameinsrc/lib/site.ts. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://estate.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildor 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].astrowithgetStaticPaths()overPROPERTIES) 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 usearia-expanded. - Generated artwork has
role="img"and descriptive labels; decorative art isaria-hidden. - The lightbox traps focus, closes with Esc and restores focus.
- Form errors are linked with
aria-describedbyand fields getaria-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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/listingsserveslistings.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/listingstolistings.html, changebuild.formatto"directory"inastro.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
areamust exactly match an option in thelocationselect (andtypemust match atypeoption). /property?id=…shows the wrong home. The id doesn't exist inPROPERTIES; 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
PROPERTIESare removed automatically. - The agent card shows no photo. Check the agent's
imgpath points at a file inpublic/. - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()rather than plain"/..."strings. /listingsshows a 404 on my host. Setbuild.format: "directory"inastro.config.mjsand 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.