Site icon FSIBLOG

Beyond Sofoximmo: Create a Better Real Estate Site via Next.js

Sofoximmo

Sofoximmo

The Belgian real estate web is a graveyard of agency templates. Sofoximmo.be is a fair example of the genre clean enough, functional, but built on patterns that haven’t aged well. The dark navy header, the four-dropdown search bar, the heavily blurred hero image: every Wallonia agency site looks roughly like this, and most of them load slowly, rank poorly, and convert worse than they should.

This piece walks through how a modern Next.js build improves on that baseline, with the actual code to recreate the sofoximmo hero and the design decisions worth changing before you ship.

Setup Build A Site Layout

Create a new Next.js project and install the icon library:

npx create-next-app@latest sofox-clone --typescript --tailwind --app
cd sofox-clone
npm install lucide-react

Drop a hero photo at public/hero-interior.jpg and replace the contents of app/page.tsx with the code below.

What is Sofoximmo Gets Right

Credit where it’s due. The orange-on-navy palette is recognisable, and that matters in a niche where most sites default to grey-on-grey. The top bar gives a phone number and email above the fold, which is the right call for a service business in Belgium. The four-field search (statut, type, localité, référence) covers the basic property hunt.

Where it falls down is in the details: the hero photo is blurred so heavily that it could be any living room anywhere, the search form lacks price and surface filters that buyers actually use, and the entire page weight on mobile pushes well past what Core Web Vitals will reward.

Why Next.js is The Right Base for Property Sites

Real estate has a specific shape that Next.js handles cleanly. You have a few dozen marketing pages that rarely change, and thousands of property listings that change daily. The framework supports both without forcing a choice.

Static generation handles the marketing routes homepage, about, contact, locality landing pages. Incremental Static Regeneration handles listings: each property gets a pre-rendered HTML page that rebuilds in the background when prices change or the property sells. Google crawls fully-rendered HTML, the user gets a fast paint, and the agent doesn’t need to redeploy when they edit a listing in the CMS.

Add next/image for automatic WebP conversion and lazy loading (real estate photos are heavy), the Metadata API for per-page SEO, and JSON-LD RealEstateListing schema on every property route, and you’ve leapfrogged 90% of the agency sites in this market.

Recreating the Sofoximmo Hero in Next.js

// app/page.tsx
"use client";

import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import {
  Phone,
  Mail,
  Facebook,
  Instagram,
  Youtube,
  MessageCircle,
  Search,
  ChevronDown,
  Menu,
  X,
} from "lucide-react";

type NavItem = {
  label: string;
  href: string;
  active?: boolean;
};

const NAV: NavItem[] = [
  { label: "ACCUEIL", href: "/", active: true },
  { label: "A VENDRE", href: "/a-vendre" },
  { label: "A LOUER", href: "/a-louer" },
  { label: "A PROPOS", href: "/a-propos" },
  { label: "CONTACT", href: "/contact" },
  { label: "ESTIMATION", href: "/estimation" },
];

export default function HomePage() {
  const [menuOpen, setMenuOpen] = useState(false);

  return (
    <main className="min-h-screen bg-[#0d1b2a] text-white">
      {/* ===== Top contact bar ===== */}
      <div className="border-b border-white/10 text-xs md:text-sm">
        <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-2">
          <div className="flex flex-wrap items-center gap-4 md:gap-6">
            <a
              href="tel:+32471853679"
              className="flex items-center gap-2 transition hover:text-orange-400"
            >
              <Phone size={14} /> 0471/85.36.79
            </a>
            <a
              href="mailto:info@sofoximmo.be"
              className="flex items-center gap-2 transition hover:text-orange-400"
            >
              <Mail size={14} /> info@sofoximmo.be
            </a>
          </div>
          <div className="flex items-center gap-4">
            <a href="#" aria-label="Facebook"><Facebook size={15} /></a>
            <a href="#" aria-label="Instagram"><Instagram size={15} /></a>
            <a href="#" aria-label="YouTube"><Youtube size={15} /></a>
            <a href="#" aria-label="WhatsApp"><MessageCircle size={15} /></a>
          </div>
        </div>
      </div>

      {/* ===== Main header / nav ===== */}
      <header className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
        <Link href="/" className="block rounded-sm bg-white px-4 py-3">
          <span className="text-2xl font-bold italic text-orange-500">Sofox</span>
          <span className="align-super text-xs text-gray-700">immo</span>
        </Link>

        {/* Desktop nav */}
        <nav className="hidden items-center gap-8 text-sm tracking-widest md:flex">
          {NAV.map((item) => (
            <Link
              key={item.label}
              href={item.href}
              className={`pb-1 transition hover:text-orange-400 ${
                item.active ? "border-b-2 border-orange-500 text-white" : ""
              }`}
            >
              {item.label}
            </Link>
          ))}
        </nav>

        {/* Mobile menu button */}
        <button
          type="button"
          className="md:hidden"
          aria-label="Toggle menu"
          aria-expanded={menuOpen}
          onClick={() => setMenuOpen((v) => !v)}
        >
          {menuOpen ? <X size={24} /> : <Menu size={24} />}
        </button>
      </header>

      {/* Mobile nav drawer */}
      {menuOpen && (
        <nav className="border-t border-white/10 md:hidden">
          <div className="mx-auto flex max-w-7xl flex-col px-4 py-3">
            {NAV.map((item) => (
              <Link
                key={item.label}
                href={item.href}
                className={`py-3 text-sm tracking-widest ${
                  item.active ? "text-orange-500" : "text-white"
                }`}
                onClick={() => setMenuOpen(false)}
              >
                {item.label}
              </Link>
            ))}
          </div>
        </nav>
      )}

      {/* ===== Hero ===== */}
      <section className="relative flex min-h-[500px] items-center justify-center overflow-hidden md:min-h-[600px]">
        <Image
          src="/hero-interior.jpg"
          alt=""
          fill
          priority
          sizes="100vw"
          className="scale-105 object-cover blur-sm"
        />
        <div className="absolute inset-0 bg-[#0d1b2a]/70" aria-hidden />

        <div className="relative z-10 w-full max-w-5xl px-4 text-center">
          <h1 className="mb-3 text-4xl font-light tracking-[0.3em] md:text-6xl">
            SOFOXIMMO
          </h1>
          <p className="mb-10 text-sm tracking-widest text-orange-500 md:text-lg">
            L&apos;IMMOBILIER RAPIDE, EFFICACE ET RENTABLE
          </p>

          <form
            action="/recherche"
            method="GET"
            className="rounded-lg bg-white/10 p-3 backdrop-blur-md md:p-4"
          >
            <div className="flex flex-col gap-3 md:flex-row">
              <SelectField name="statut" placeholder="À vendre">
                <option value="vente">À vendre</option>
                <option value="location">À louer</option>
              </SelectField>

              <SelectField name="type" placeholder="Type de biens">
                <option value="maison">Maison</option>
                <option value="appartement">Appartement</option>
                <option value="terrain">Terrain</option>
                <option value="commerce">Commerce</option>
              </SelectField>

              <SelectField name="localite" placeholder="Localité">
                <option value="charleroi">Charleroi</option>
                <option value="mons">Mons</option>
                <option value="namur">Namur</option>
                <option value="liege">Liège</option>
              </SelectField>

              <input
                type="text"
                name="reference"
                placeholder="Référence"
                className="flex-1 rounded-md border border-orange-500 bg-white/95 px-4 py-3 text-gray-700 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
              />

              <button
                type="submit"
                aria-label="Rechercher"
                className="flex items-center justify-center rounded-md bg-orange-500 px-6 py-3 text-white transition hover:bg-orange-600"
              >
                <Search size={20} />
              </button>
            </div>
          </form>
        </div>
      </section>
    </main>
  );
}

// Reusable select with custom chevron
function SelectField({
  name,
  placeholder,
  children,
}: {
  name: string;
  placeholder: string;
  children: React.ReactNode;
}) {
  return (
    <div className="relative flex-1">
      <select
        name={name}
        defaultValue=""
        className="w-full appearance-none rounded-md border border-orange-500 bg-white/95 px-4 py-3 pr-10 text-gray-700 focus:outline-none focus:ring-2 focus:ring-orange-500"
      >
        <option value="" disabled>
          {placeholder}
        </option>
        {children}
      </select>
      <ChevronDown
        size={18}
        className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500"
      />
    </div>
  );
}

What Changed in The Build Review

The NAV array is now typed as NavItem[] with active marked optional, so TypeScript stops complaining when accessing .active on items that don’t define it. Every <option> has an explicit value so form submissions produce clean URL parameters like ?statut=vente&type=maison instead of ?statut=%C3%80%20vendre. The hero is min-h-[500px] on mobile and min-h-[600px] from md: up, which keeps small phones from scrolling a wall of dark space before they see anything else.

The component is now "use client" because the mobile menu uses useState. If you want to keep the page as a server component for SEO, lift the header into its own client component (components/Header.tsx) and import it that way only the small interactive piece ships JavaScript to the browser, and the hero remains server-rendered.

A few things that quietly matter in this component:

The top bar phone number uses tel:+32... instead of the local format, so it works internationally on mobile dialers. The hero <Image> is marked priority because it’s the LCP element without that flag, Lighthouse will dock you several points. The decorative overlay has aria-hidden so screen readers don’t announce it. The custom SelectField swaps the browser’s native dropdown arrow for a styled chevron that matches the orange theme, because the default arrow on Chrome looks like Windows 95.

Where to Push Further Than Sofoximmo

The recreation above is faithful, but the original design has limits worth fixing before you ship something to a real client.

A Note on the Rest of the Build

The hero is the visible part, but it’s not where the real engineering lives. A production real estate site needs a database (PostgreSQL with PostGIS for radius searches), an image pipeline (Cloudinary or Bunny CDN), an instant search layer (Meilisearch or Typesense), a map component (Mapbox is cheaper and more flexible than Google Maps), and a CMS interface so the agent can add listings without touching code (Directus or Payload work well alongside Next.js).

Pull those pieces together and the result isn’t just “a faster sofoximmo.” It’s a property search experience that ranks for the queries buyers actually type, loads in under two seconds on a mid-range Android, and gives the agent a back office they don’t need to call you about every Tuesday.

Exit mobile version