Building a Premium Real Estate Site with TanStack Start

This Article Was Last Updated
2026-07-20
Building a Premium Real Estate Site with TanStack Start

Why TanStack Start

When I took on the Shaon Landmarks project — a premium real estate showcase for an architectural firm in Dhaka — I needed SSR for SEO, file-based routing for clean organization, and a React-native DX without the weight of Next.js. TanStack Start checked every box.

The site needed to rank well for property searches, load fast on slow mobile connections in Bangladesh, and feel premium enough to match the firm's architectural projects. TanStack Start gave me SSR out of the box with React 19, and the file-based routing meant I could organize properties, blog posts, and service pages without wrestling with a router config.

The Design-First Approach

I made a decision early that saved me dozens of hours later: define the design system before writing a single component.

Instead of picking colors as I went, I documented every token in a design-system.md file first:

## Typography Scale
- Display: 3.5rem / 4rem line-height
- H1: 2.5rem / 3rem
- H2: 2rem / 2.5rem
- Body: 1rem / 1.5rem
- Small: 0.875rem / 1.25rem

## Colors
- Primary: #1a1a2e (deep navy)
- Accent: #e94560 (architectural red)
- Surface: #f8f9fa
- Text: #212529

## Spacing
- xs: 0.25rem, sm: 0.5rem, md: 1rem, lg: 1.5rem, xl: 2rem, 2xl: 3rem

This meant every component I built used the same tokens. When the client asked to "make the headings bolder," I changed one value in the token file, and it cascaded everywhere. No hunting through 50 components to find inline font-size: 28px declarations.

GSAP Scroll Animations with Lenis

The site needed to feel premium — not just look premium. Static pages feel flat. I added GSAP ScrollTrigger for scroll-triggered animations and Lenis for buttery smooth scrolling.

The setup was straightforward. Lenis handles the smooth scroll globally, and GSAP's ScrollTrigger fires animations as sections enter the viewport:

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import Lenis from 'lenis';

gsap.registerPlugin(ScrollTrigger);

const lenis = new Lenis();

lenis.on('scroll', ScrollTrigger.update);

gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
});

gsap.ticker.lagSmoothing(0);

For property listings, I created a staggered reveal where each property card fades in and slides up as you scroll:

gsap.from('.property-card', {
  scrollTrigger: {
    trigger: '.property-grid',
    start: 'top 80%',
  },
  y: 60,
  opacity: 0,
  duration: 0.8,
  stagger: 0.15,
  ease: 'power3.out',
});

The key insight was keeping animations subtle. Real estate buyers are adults making major financial decisions — they don't need spinning logos. A 60px upward slide with a 0.8s duration feels professional without being distracting.

Leaflet Maps with Custom Markers

Each property needed to be shown on an interactive map. The default Leaflet markers looked generic, so I created custom SVG markers that matched the site's design tokens.

import L from 'leaflet';

const customIcon = L.divIcon({
  className: 'custom-marker',
  html: `
    <div style="
      background: #1a1a2e;
      color: white;
      padding: 6px 12px;
      border-radius: 8px;
      font-weight: 600;
      font-size: 14px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.15);
      white-space: nowrap;
    ">
      ৳${price}
    </div>
  `,
  iconSize: [80, 40],
  iconAnchor: [40, 40],
});

The popup was styled to match the site — no default white box with blue links. I used the same border-radius, shadow, and typography tokens from the design system.

For the map itself, I chose a muted style tile layer that wouldn't compete with the property images:

<L.MapContainer center={[23.8103, 90.4125]} zoom={13} style={{ height: '500px' }}>
  <TileLayer
    url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
    attribution='&copy; OpenStreetMap contributors &copy; CARTO'
  />
  {properties.map(property => (
    <Marker
      key={property.id}
      position={[property.lat, property.lng]}
      icon={customIcon}
    >
      <Popup>{property-popup-content}</Popup>
    </Marker>
  ))}
</L.MapContainer>

The Image Pipeline

Real estate sites live and die by their images. The firm's architectural photos were 3-5MB each. Loading 20 of those on a property listing page would destroy performance.

I built a Sharp-based build script that runs at build time. It takes the original images and generates three variants:

import sharp from 'sharp';
import { readdir } from 'fs/promises';

async function processImages(inputDir: string, outputDir: string) {
  const files = await readdir(inputDir);

  for (const file of files) {
    const input = `${inputDir}/${file}`;
    const name = file.replace(/\.\w+$/, '');

    // Full-size WebP (max 1200px wide)
    await sharp(input)
      .resize(1200, null, { withoutEnlargement: true })
      .webp({ quality: 80 })
      .toFile(`${outputDir}/${name}-full.webp`);

    // Thumbnail (400px wide)
    await sharp(input)
      .resize(400, null, { withoutEnlargement: true })
      .webp({ quality: 70 })
      .toFile(`${outputDir}/${name}-thumb.webp`);

    // Blur placeholder (20px wide, base64)
    const blurBuffer = await sharp(input)
      .resize(20)
      .blur()
      .webp({ quality: 20 })
      .toBuffer();

    const blurBase64 = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
    console.log(`${name}: ${blurBase64.slice(0, 50)}...`);
  }
}

This cut the total image payload by about 70%. The blur placeholders give users immediate visual feedback while the full images load — no more white rectangles flashing in.

The EMI Calculator

Prospective buyers wanted to estimate monthly payments without leaving the site. I built an interactive EMI calculator as a client-side component.

The challenge was keeping it responsive during rapid slider drags. Calculating loan amortization on every pixel of slider movement was causing jank. I used useDeferredValue to keep the calculations off the main thread:

function EMICalculator() {
  const [loanAmount, setLoanAmount] = useState(5000000);
  const [interestRate, setInterestRate] = useState(8.5);
  const [tenure, setTenure] = useState(20);

  const deferredAmount = useDeferredValue(loanAmount);
  const deferredRate = useDeferredValue(interestRate);
  const deferredTenure = useDeferredValue(tenure);

  const monthlyRate = deferredRate / 100 / 12;
  const months = deferredTenure * 12;
  const emi =
    (deferredAmount * monthlyRate * Math.pow(1 + monthlyRate, months)) /
    (Math.pow(1 + monthlyRate, months) - 1);

  return (
    <div>
      <Slider value={loanAmount} onValueChange={setLoanAmount} />
      <p>Monthly EMI: ৳{Math.round(emi).toLocaleString()}</p>
    </div>
  );
}

The key was separating the slider's value (updates instantly) from the deferredValue (recalculates when the browser is idle). Users see smooth slider movement, and the EMI number updates a frame or two later — imperceptible but technically correct.

What I'd Do Differently

If I built this again, I'd add generateStaticParams for the property detail pages. Currently they're server-rendered on every request, which works but could be static since the property data doesn't change between deployments.

I'd also add proper error boundaries around the Leaflet map. If the tile layer fails to load (common on slow connections in South Asia), the whole property page goes blank. A fallback image or retry mechanism would make it more resilient.

Results

The site loads in under 2 seconds on 3G, scores 95+ on Lighthouse, and the architectural firm's clients can browse properties, check locations on the map, and estimate EMI payments — all without hitting a backend API. The design token system made it easy to hand off to their marketing team for content updates.

TanStack Start was the right call. It gave me the SSR and routing I needed without the opinionated conventions I didn't. For content-heavy sites where you want React's component model but need SEO, it's a solid choice.