DEV Community

Marco Schulz
Marco Schulz

Posted on Originally published at facadeui.com

Real estate website template for Next.js: blocks, not a theme

When an agent or a small brokerage asks me for a website, the first thing they send is a link to a theme they found on a marketplace. It looks fine in the demo. Six months after launch it looks like every other brokerage in the state, the listing grid needs a plugin update, and nobody remembers how the contact form sends email. This article is the plan I use instead: a Next.js site assembled from blocks you own, in the order a buyer or seller actually reads a page.

Why themes age badly

A theme is one decision made for you, forever. Layout, typography, animation library, form handler and color system arrive as a single bundle. The day you want to change one of them you discover they are all tied together.

Three problems show up on the themed real estate sites I get asked to fix:

  • The listing data is shaped for the theme's demo, not for the MLS feed or the spreadsheet the agent actually has. Mapping one to the other becomes a permanent chore.
  • The animations are decorative. Things fade in, but nothing on the page responds to the visitor. A buyer who wants to filter by price gets a page reload.
  • Lead capture is a generic contact form. Sellers who want to know what their home is worth are the most valuable visitors on the site, and the form treats them like someone asking about parking.

Blocks solve this because each block is a single TypeScript file in your repo. You install the ones you need, pass your data as props, and edit the source when a client wants something specific. There is no theme to update.

The 10 block page plan

This is the block order for an agent site. It is the same order I use on the real estate agent page, and each block links to its component docs.

  1. Hero, real estate: a full width hero with a Buy, Rent, Sell search panel and animated stats. The search mode changes the labels, so "Location" becomes "Your address" when a visitor switches to Sell and the button turns into "Get valuation".
  2. Agent card: the agent's name, rating, license and languages, with call, email and tour actions. On an agent site this belongs above the fold on mobile.
  3. Property listing grid: filterable and sortable listings with saved homes and a Load more button. The grid re-layouts with a spring when a filter changes instead of blinking.
  4. Stats band: four animated numbers. Homes sold, average days on market, years in business, neighborhoods covered. Use real numbers from the agent or leave the block out.
  5. Testimonials wall: reviews with names and neighborhoods.
  6. Home valuation funnel: the seller lead magnet. Five steps, address first, estimate range at the end.
  7. Mortgage calculator: the buyer tool. I covered the formula and the install in a separate article.
  8. FAQ section with FAQPage JSON-LD.
  9. Contact section with a form, hours and a map card.
  10. Big footer with NAP details, hours and the brokerage license line.

Ten blocks is the whole site. A neighborhood page reuses blocks 3, 4 and 8 with different data.

The data shape for listings

Before you touch the hero, decide what a listing looks like in code. The listing grid takes a PropertyListing[] array, and every other block that shows property data can be fed from the same records.

import type { PropertyListing } from "@/components/facade/property-listing-grid";

export const listings: PropertyListing[] = [
  {
    id: "mls-4108",
    type: "house",
    title: "Modern farmhouse with pool",
    address: "4108 Avenue G",
    city: "Hyde Park, Austin",
    price: 1_285_000,
    beds: 4,
    baths: 3.5,
    sqft: 3120,
    daysOnMarket: 2,
    status: "New",
    href: "/listings/mls-4108",
    imageSrc: "/photos/mls-4108/cover.jpg",
  },
];
Enter fullscreen mode Exit fullscreen mode

Two decisions in that shape matter later. type is a union of "house" | "condo" | "townhome" because the filter chips in the grid are built from it. status is optional and limited to "New" | "Open house" | "Price drop" | "Pending", because a badge vocabulary that grows without limit ends up with twelve colors nobody can read.

Where does the array come from? For a solo agent with twenty listings, a JSON file in the repo, updated through a CMS or a spreadsheet export, costs nothing. For a brokerage with an MLS feed, a server component fetches the feed, maps it to PropertyListing once, and passes the array down. The grid does not care which.

Rendering the grid is one component:

import { PropertyListingGrid } from "@/components/facade/property-listing-grid";
import { listings } from "@/data/listings";

export function Listings() {
  return (
    <PropertyListingGrid
      eyebrow="Featured homes"
      heading="Homes on the market this week"
      listings={listings}
      pageSize={6}
      defaultSort="newest"
      onToggleSave={(id, saved) => console.log(id, saved)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Saved homes live in component state. To survive a reload, write to localStorage in onToggleSave; restoring the set on load is a small edit to the installed source.

The lead capture flow

A real estate site has two visitors with two different questions. Buyers ask "Can I afford this?" and sellers ask "What is my home worth?" Each gets its own block, and the two blocks hand leads to the same place.

The valuation funnel asks for the address first. That ordering is deliberate: an address is low commitment and feels like the start of a tool, not the start of a form. Property type, size and condition follow, and the contact details come last, after the visitor has invested a minute. The built-in estimator multiplies square footage by a basePricePerSqft you set for the market, adjusts for condition, and returns a low, mid and high figure. It is clearly labeled as an estimate, not an appraisal, and the estimate prop lets you swap in a real valuation API later without touching the UI.

import { ValuationFunnel } from "@/components/facade/valuation-funnel";

<ValuationFunnel
  marketLabel="Austin, TX"
  basePricePerSqft={312}
  onLead={(data, estimate) => fetch("/api/leads", { method: "POST", body: JSON.stringify({ data, estimate }) })}
  bookLabel="Book a walkthrough"
  bookHref="/book"
/>
Enter fullscreen mode Exit fullscreen mode

onLead is where the site earns money. I post to a route handler that writes the lead to the agent's CRM and sends a notification. The mortgage calculator's onChange callback is the buyer side of the same idea: when a buyer settles on a payment, the pre-approval button next to the donut already knows the numbers.

Install and deploy on Vercel

All four featured blocks are Pro items. Set up the private registry once with your license key (the one time setup is in the installation docs), then install the page in a single command:

npx shadcn@latest add @facade/hero-real-estate @facade/property-listing-grid @facade/valuation-funnel @facade/mortgage-calculator
Enter fullscreen mode Exit fullscreen mode

The free blocks on the list, agent card, stats band, FAQ section and the footer, install from the public registry URL, for example npx shadcn@latest add https://facadeui.com/r/agent-card.json. Every file lands in components/facade/, depends only on motion and lucide-react, and uses the semantic Tailwind tokens your shadcn setup already defines. Change --brand in globals.css and the whole site re-themes.

Deploying is vercel from the project root or a Git push. Because listings can render on the server, the listing pages are static HTML with the interactive grid hydrating on top. That is what keeps Largest Contentful Paint inside Google's 2.5 second "good" threshold on a phone, which a theme loading several jQuery plugins rarely manages.

FAQ

Is this a template I can download as a zip?

No. It is an ordered set of blocks you install into your own Next.js project with the shadcn CLI. You get the same result as a template, a complete page, but every section is a file you own and can edit. The order and the data shapes are documented on the industry page.

Can I connect an IDX or MLS feed?

Yes. Fetch the feed in a server component, map each record to the PropertyListing type and pass the array to the grid. The grid has no opinion about where the data comes from. IDX compliance rules about attribution and refresh intervals are between you and your MLS.

Does it work with the Pages Router or only the App Router?

The blocks are plain React client components. They work in the App Router, the Pages Router, Vite and Remix, as long as Tailwind v4 and the shadcn tokens are set up. The install docs use the App Router because that is what new Next.js projects start with.


Originally published at https://facadeui.com/blog/real-estate-website-template-nextjs?utm_source=devto&utm_medium=social&utm_campaign=art-real-estate-website-template-nextjs

Top comments (2)

Collapse
 
mp3sss31 profile image
mp3sss •

The "theme is one decision made for you, forever" line maps almost one-to-one onto desktop app scaffolding: a starter template that wires your build, updater and UI together feels fast until you need to swap one of them, and then every layer resists. The block model is the right antidote — each block a single file you own, with props as the explicit contract, so changing a listing source or a form handler stays a local edit instead of a rewrite. And treating your most valuable visitors differently (a seller wanting a valuation vs someone asking about parking) is a sharp reminder that lead-capture is segmentation, not just plumbing.

Collapse
 
marcoschulz123 profile image
Marco Schulz •

Thanks for sharing this! I hadn’t thought about the desktop app comparison, but it fits really well. Being able to change one part without untangling everything else is exactly what I’m aiming for. And yes, the visitor’s actual question should shape the form. Appreciate the thoughtful feedback! 🙌