All work

Real estate agency

An editorial website for a property agency in Morocco, with a back office where the team publishes listings, photographs and filmed visits on its own.

Role
Design and full-stack development
Status
In development
Category
Agency website and back office
Place and year
Morocco, 2026
Stack
Next.js, React, TypeScript, Tailwind CSS, GSAP, Lenis, SQLite, Zod
Links
Not publicly launched yet, and the source code isn’t public. The agency’s name and logo are left out.
Home page: a villa at dusk, full bleed.
Featured properties in an asymmetric grid.
Property page with key figures and a landscape photograph.
A property page on a phone.
captioned like a magazine
filmed visits, in 9:16

The story

  1. The challenge

    A property agency selling and letting villas, riads and apartments in Morocco wanted every property presented like a feature in a magazine, and a back office simple enough to publish listings, photographs and filmed visits without help.

  2. The approach

    An editorial site in French: numbered chapters, captioned full-bleed photographs and an asymmetric property grid, with GSAP motion on Lenis. Behind it, a protected back office on SQLite for listings, photo uploads, vertical video tours and enquiries.

  3. The result

    The site and the back office are built and seeded with nine demo listings. The agency’s own photography, its real listings and email notifications are what remain before launch.

Public site

  • Property catalogue with search and filters for transaction, type, price, bedrooms and surface.
  • Property pages with a gallery you can browse with the arrow keys, key figures and a technical sheet.
  • Filmed visits: an uploaded vertical video played in 9:16, or a link to the property’s TikTok post.
  • A seller journey: a free estimate, a four-step mandate and an enquiry form already set to “sell”.
  • Motion that respects reduced motion: the grain freezes, movement stops and content stays visible.

Back office

  • Sign-in enforced by middleware on every admin page, with signed session cookies and bcrypt-hashed passwords.
  • A dashboard with counters, the latest enquiries and recent listings.
  • Listings: create, edit, delete, feature and set their status.
  • Photo uploads re-encoded to JPEG at 2000 px at most, with metadata stripped and every file proven to be an image.
  • Enquiries tracked from new to handled to archived.

Under the hood

real excerpts from the codebase

Never trust an upload

A declared MIME type is only the browser’s claim. Every photo is decoded and re-encoded on the server, which strips its metadata and rejects anything that isn’t really an image.

src/app/admin/actions.ts

if (!TYPES_IMAGE.includes(f.type)) {
  return { ok: false, message: `Format refusé : ${f.name}. JPEG, PNG, WebP ou AVIF.` };
}
if (f.size > TAILLE_MAX) {
  return { ok: false, message: `Fichier trop lourd : ${f.name} (12 Mo maximum).` };
}

const nom = `${crypto.randomUUID()}.jpg`;
try {
  // Re-encoding strips metadata and proves the bytes really are an image —
  // a declared MIME type is only a claim by the client.
  await sharp(Buffer.from(await f.arrayBuffer()))
    .rotate()
    .resize({ width: 2000, withoutEnlargement: true })
    .jpeg({ quality: 82, mozjpeg: true })
    .toFile(path.join(dossier, nom));
} catch {
  return { ok: false, message: `Fichier illisible : ${f.name}. Ce n'est pas une image valide.` };
}

One gate for the back office

Every admin route goes through the same check before it renders; without a valid session the visitor lands on the sign-in page and returns where they were going.

src/middleware.ts

export async function middleware(req: NextRequest) {
  const session = await lireJeton(req.cookies.get(NOM_COOKIE)?.value);

  if (!session) {
    const url = new URL("/admin/connexion", req.url);
    url.searchParams.set("suite", req.nextUrl.pathname);
    return NextResponse.redirect(url);
  }
  return NextResponse.next();
}

// Everything under /admin except the login page itself.
export const config = {
  matcher: ["/admin/((?!connexion).*)", "/admin"],
};

Decisions

A reference, not a copy

An editorial reference site set the direction. Its type scale and layout grammar were adopted; the copy, the photography grading and every line of markup were written for this project.

Honest structured data

Real estate listing data for search engines stays off until the listings are real, because publishing example offers to Google would mislead.

Video where the audience is

The agency films its properties vertically, so each listing takes a 9:16 file or simply links to the TikTok post that already carries the views.

Home page with a full-bleed villa at dusk.
Home
Featured properties in an asymmetric grid.
Featured properties
Home page on a phone.
Home on a phone
Property catalogue with search and filters.
Catalogue with search and filters
Property cards in alternating crops.
The asymmetric property grid
Property page with key figures and a landscape photograph.
Property page
A property page on a phone with its gallery.
A property on a phone
Gallery and technical sheet of a property.
Gallery and technical sheet
Seller page: “Vous avez un bien à vendre ou à louer.”
The seller journey
The four-step mandate explained.
A four-step mandate
Back office dashboard with counters, latest enquiries and recent listings.
Back office dashboard
Back office list of listings with status and featured toggles.
Listings
Listing editor with photo and video uploads.
Listing editor with photo and video uploads

Status

Photography in the build is licensed stock, graded to match, and the nine listings and enquiries are demo content. The agency’s name, logo, phone number and social accounts are left out of this case study.