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:
- 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.
- 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.
- filename in the query string so the file saves with a real name, not the handle.
- 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.
- 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:
- e.preventDefault() in onDragOver is mandatory. Without it, the browser ignores the drop and opens the file instead. This is the most common reason a from-scratch drop zone “doesn’t work.”
- role=”button” + tabIndex={0} + onKeyDown make the zone keyboard-accessible.
- e.target.value = “” after the change handler lets users re-upload the same file. File inputs only fire change on value change.
- accept=”image/*” filters the OS picker but doesn’t stop a user dragging in a PDF. We filter in handleFiles.
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:
- Cap the uploader at 10 – images.length so users can’t exceed the limit across batches.
- The first uploaded image is the cover, which is what shows up in search results.
- Render previews from galleryThumb, not the original. The uploader gives you the handle, not the bytes, so every preview flows through the same CDN as production.
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
- Move listings out of localStorage into a real database
- Set NEXT_PUBLIC_FILESTACK_API_KEY in your hosting environment
- Configure Filestack Security Policies (origin lock, MIME image/*, ~10MB cap)
- Add a moderation hook via Filestack Intelligence
- Consider Workflows for chained processing
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.
Favour is a Developer Relations engineer with 3+ years of experience helping developers adopt and succeed with technical products. Combining a strong software engineering background with a passion for community building, content creation, and developer education, he specializes in translating complex technologies into clear, engaging experiences for developer audiences.
