Site icon Filestack Blog

Build a Real Estate Listings App with Filestack

fs-realestate-banner

In real estate, the photos are the product. A buyer scrolling a results page won’t read your description if the cover image looks like it was shot on a flip phone. A detail page that ships a 4MB hero over LTE loses them before scroll.

Doing images well usually means building a small pipeline: an upload endpoint that streams to S3, a worker pool for resizing, a CDN distribution, a queue for retries. That’s a meaningful chunk of engineering before you’ve shown a single listing. This guide walks through Horizon Pro, a real-estate marketplace built on Filestack, which replaces that pipeline with a single SaaS layer.

What we’re building

A user signs in, drags up to 10 photos onto a listing form, fills in price, beds, baths, and location, then publishes. The listing appears on the home grid with a cover thumbnail. On the detail page, the same handle powers a hero, a gallery strip, and a full-resolution lightbox. Three sizes, one upload, zero image-processing code.

Stack

Layer Tech
Framework Next.js 16 (App Router)
Upload, storage, CDN, transforms Filestack
Client state Zustand (with localStorage persist)
Styling Tailwind v4
Language TypeScript

Filestack handles uploads (direct from the browser), storage (an S3 bucket you don’t provision), the CDN (edge POPs you don’t configure), and on-the-fly transformations driven by URL. Everything else is replaceable.

Listings live in browser storage so the demo is self-contained. Drop in Postgres, Turso, or Supabase later by swapping the Zustand store for API calls. The upload and transformation layers stay the same.

Step 1: Get your Filestack API key

Sign up at filestack.com, grab the API key, and drop it in .env.local:

NEXT_PUBLIC_FILESTACK_API_KEY=your_api_key_here

The NEXT_PUBLIC_ prefix exposes the key to the browser, which is necessary because uploads go straight from the user’s machine to Filestack with no server hop. For production, lock the key down with Security Policies (allowed origins, MIME types, max size).

Step 2: Build a custom drop zone

Filestack ships a File Picker widget, but a marketplace usually wants the upload UI to feel native to its design system. We’ll talk to the File API directly with one fetch and build the drop zone from scratch. No SDK, no signed URL to pre-request. The byte path is user → Filestack → CDN; your backend isn’t in it.

The upload function

// components/features/FilestackUploader.tsx

const FILESTACK_STORE_URL = "https://www.filestackapi.com/api/store/S3";



async function uploadOne(file: File): Promise<IUploadedImage> {

  const apiKey = process.env.NEXT_PUBLIC_FILESTACK_API_KEY!;

  const url = `${FILESTACK_STORE_URL}?key=${apiKey}&filename=${encodeURIComponent(file.name)}`;



  const res = await fetch(url, {

    method: "POST",

    headers: { "Content-Type": file.type || "application/octet-stream" },

    body: file,

  });



  if (!res.ok) throw new Error(`Upload failed (${res.status})`);



  const data = await res.json();

  return {

    handle: data.url.split("/").pop() ?? "",

    url: data.url,

    filename: data.filename,

    mimetype: data.type,

    size: data.size,

  };

}

Notes on the request:

  1. Path ends in /S3. That’s the storage backend. Filestack also supports azure, gcs, dropbox, rackspace. The default S3 bucket is fine if you don’t bring your own.
  2. API key goes in the query string. The File API is designed to be called from the browser; the key is rate-limited and origin-restricted once you turn on Security Policies.
  3. filename in the query string so the file saves with a real name, not the handle.
  4. Content-Type falls back to application/octet-stream for files the browser can’t sniff (.heic, etc.). Filestack detects the real type from the bytes.
  5. Body is the File object. No FormData, no base64; fetch streams it as raw bytes.

What you get back

{

  "url": "https://cdn.filestackcontent.com/AbCdEfGh1234567",

  "filename": "kitchen.jpg",

  "type": "image/jpeg",

  "size": 481923,

  "key": "qZx7..._kitchen.jpg"

}

The last segment of url is the handle. Save it. Everything downstream reads from it: thumbnails, hero images, format conversion, watermarks. Store the handle alongside the listing record and you’ve decoupled your data model from your image pipeline.

const handle = data.url.split("/").pop() ?? "";

The drop zone

Two interaction modes to support: clicking to open the system picker, and dragging files onto a target. Both feed a FileList to the same handler. The cleanest way is a hidden <input type=”file”> that gets .click()ed when the drop zone is clicked.

const inputRef = useRef<HTMLInputElement>(null);

const [isDragging, setIsDragging] = useState(false);



return (

  <div

    role="button"

    tabIndex={0}

    onClick={() => inputRef.current?.click()}

    onDragOver={(e) => {

      e.preventDefault();          // required, otherwise drop won't fire

      setIsDragging(true);

    }}

    onDragLeave={() => setIsDragging(false)}

    onDrop={(e) => {

      e.preventDefault();

      setIsDragging(false);

      void handleFiles(e.dataTransfer.files);

    }}

    onKeyDown={(e) => {

      if (e.key === "Enter" || e.key === " ") {

        e.preventDefault();

        inputRef.current?.click();

      }

    }}

    className={isDragging ? "drop-zone drop-zone--active" : "drop-zone"}

  >

    Click or drag photos here. Select multiple files at once.



    <input

      ref={inputRef}

      type="file"

      accept="image/*"

      multiple

      className="hidden"

      onChange={(e) => {

        if (e.target.files) void handleFiles(e.target.files);

        e.target.value = "";       // allow re-selecting the same file

      }}

    />

  </div>

);

Things easy to miss:

Handling the file list

async function handleFiles(incoming: FileList | File[]) {

  const files = Array.from(incoming)

    .filter((f) => f.type.startsWith("image/"))

    .slice(0, maxFiles);



  if (files.length === 0) return;



  const results = await Promise.all(

    files.map(async (file) => {

      try {

        return await uploadOne(file);

      } catch (err) {

        console.error(`Upload failed for ${file.name}:`, err);

        return null;

      }

    }),

  );



  const successful = results.filter((r): r is IUploadedImage => r !== null);

  if (successful.length > 0) onUploadDone(successful);

}

Two things: the per-file try/catch lets the batch partial-succeed (one bad file doesn’t lose the rest), and Promise.all runs uploads in parallel. For more than 10 files at once you’d bound concurrency with something like p-limit, but a listing tops out at 10 photos.

Upload progress (optional)

fetch doesn’t expose upload progress events. If progress bars matter, swap to XHR:

function uploadWithProgress(file: File, apiKey: string, onProgress: (pct: number) => void) {

  return new Promise<FilestackResponse>((resolve, reject) => {

    const xhr = new XMLHttpRequest();

    const qs = new URLSearchParams({ key: apiKey, filename: file.name });



    xhr.open("POST", `https://www.filestackapi.com/api/store/S3?${qs}`);

    xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");

    xhr.upload.onprogress = (evt) => {

      if (evt.lengthComputable) onProgress(Math.round((evt.loaded / evt.total) * 100));

    };

    xhr.onload = () => xhr.status < 300

      ? resolve(JSON.parse(xhr.responseText))

      : reject(new Error(String(xhr.status)));

    xhr.onerror = () => reject(new Error("Network error"));

    xhr.send(file);

  });

}

For Horizon Pro a per-file spinner is enough. Reach for XHR when files are big enough that a percentage actually helps users decide whether to wait.

Step 3: Attach photos to a listing

A listing is a small object (price, beds, baths, address) plus a list of images:

// forms/ListingForm.tsx

const [images, setImages] = useState<IUploadedImage[]>([]);



{images.length < 10 && (

  <FilestackUploader

    maxFiles={10 - images.length}

    onUploadDone={(uploaded) => setImages((prev) => [...prev, ...uploaded])}

  />

)}



{images.map((img, idx) => (

  <div key={img.handle} className="relative aspect-square">

    <img src={imagePresets.galleryThumb(img.handle)} alt={img.filename} />

    {idx === 0 && <span className="badge">Cover</span>}

    <button onClick={() => removeImage(idx)}>×</button>

  </div>

))}

Three details:

When the form submits, save { handle, url, filename, mimetype, size, order } for each image alongside the listing. In our demo that’s a Zustand addListing action; in production it’s POST /listings.

Step 4: One handle, many sizes

Every Filestack handle is a key into a Processing API that resizes, crops, converts, compresses, and filters on demand. The whole API is URL-driven. You build a URL, point an <img> at it, Filestack runs the transform on first request, caches the result globally, and serves it from the edge on every subsequent hit.

Build the URLs in one place:

// lib/filestack.ts

const CDN_BASE = "https://cdn.filestackcontent.com";



export function getTransformedUrl(handle: string, opts: ITransformOptions = {}): string {

  const tasks: string[] = [];



  if (opts.width || opts.height) {

    const parts: string[] = [];

    if (opts.width)  parts.push(`width:${opts.width}`);

    if (opts.height) parts.push(`height:${opts.height}`);

    if (opts.fit)    parts.push(`fit:${opts.fit}`);

    tasks.push(`resize=${parts.join(",")}`);

  }

  if (opts.quality) tasks.push(`quality=value:${opts.quality}`);

  if (opts.format)  tasks.push(`output=format:${opts.format}`);



  return tasks.length === 0

    ? `${CDN_BASE}/${handle}`

    : `${CDN_BASE}/${tasks.join("/")}/${handle}`;

}



export const imagePresets = {

  thumbnail:    (h: string) => getTransformedUrl(h, { width: 400,  height: 270, fit: "crop", format: "webp", quality: 80 }),

  card:         (h: string) => getTransformedUrl(h, { width: 600,  height: 400, fit: "crop", format: "webp", quality: 85 }),

  hero:         (h: string) => getTransformedUrl(h, { width: 1200, height: 800, fit: "crop", format: "webp", quality: 90 }),

  galleryThumb: (h: string) => getTransformedUrl(h, { width: 200,  height: 150, fit: "crop", format: "webp", quality: 75 }),

  full:         (h: string) => `${CDN_BASE}/${h}`,

};

Every surface uses the same handle through a different preset:

<img src={imagePresets.card(image.handle)} />          // homepage grid

<img src={imagePresets.hero(image.handle)} />          // detail hero

<img src={imagePresets.galleryThumb(image.handle)} />  // gallery strip

<img src={imagePresets.full(image.handle)} />          // lightbox

A card thumbnail’s URL looks like:

https://cdn.filestackcontent.com/resize=width:600,height:400,fit:crop/output=format:webp/quality=value:85/AbCdEfGh1234567

Transformations chain left-to-right. Adding a new responsive breakpoint is one preset and zero infrastructure.

Step 5: Search and filter

Listings in client state plus URL-derived image URLs means search is pure computation:

// components/features/SearchResults.tsx

const params = useSearchParams();

const listings = useListingStore((s) => s.listings);



const results = useMemo(() => {

  return listings.filter((l) => {

    if (city && !l.city.toLowerCase().includes(city)) return false;

    if (minPrice && l.price < minPrice) return false;

    if (minBeds && l.bedrooms < minBeds) return false;

    if (type && l.propertyType !== type) return false;

    return true;

  });

}, [listings, params]);

URL params drive the sidebar via useRouter().replace(), so a filtered view is shareable and bookmarkable. When you move to a database, this filter becomes a WHERE clause; the UI doesn’t change.

Beyond listings

The same handle pattern extends to most of the surrounding product:

Surface Filestack feature
Agent headshots fit:crop + rounded_corners=radius:
Floor plan PDFs Filestack Document Viewer
Watermarked previews Chain watermark= over the cover image
Video walkthroughs Video API over Filestack’s CDN
Auto-tag rooms, moderation Filestack Intelligence

Production checklist

Further reading

Topic Link
File API Reference
All transformations Processing API
File Picker widget Docs
Security Policies
AI moderation, tagging Intelligence
SDKs Filestack SDKs

Final thoughts

Most real-estate apps reinvent an image pipeline they don’t need to build: an upload route, an S3 bucket, a worker for resizing, a CDN distribution. Filestack collapses that into a handle and a URL convention.

Build the preset library once, route every <img src> through it, and adding a new size is one line. The same pattern works for the next image-heavy app you build (e-commerce, CMS, social, SaaS), so the abstraction travels with you.

Try the live demo here or grab the source on GitHub.



Exit mobile version