All work

Al Maruf Photography

A wildlife archive, and the CMS that runs it

50 subjects · 87 frames · 373+ bird species documented · 15 films

Next.js 15TypeScriptSupabasePrismaPostgreSQLTailwind CSSVercelCustom CMS
Role
Solo Full-Stack Engineer & Designer
Company
Personal project
Period
2026 – Present
Location
Dhaka, Bangladesh
Domain
CMS & digital archive

Overview

Al Maruf Photography is the portfolio and working archive of Gazi Mahmud Al Maruf — a biomedical engineer in Satkhira, Bangladesh, who has spent six years and a thousand-plus field hours documenting the country's birds, mammals, reptiles and butterflies. The public site is only half the project. The other half is the CMS I built underneath it, which is what turns a photographer's hard drive into a queryable, citable record.

The design problem is that this is not a gallery, it is a catalogue. A gallery shows pretty pictures; a catalogue makes a claim about what a thing is. Every frame belongs to a subject, and every subject carries a common name, a scientific binomial and — where it was ever recorded — a Bengali name. That is a taxonomy, not a tag cloud, and it forced the data model to treat the subject as the primary entity with photographs filed underneath it, rather than the other way round.

The second constraint is scientific honesty, and it shaped the schema more than any UI decision. Most of these frames were shot years before anyone thought about publishing them, so locality and date simply are not known. The archive renders those fields as "not recorded" rather than guessing, which means every metadata field had to be nullable and every template had to have a defined empty state. An archive that quietly invents a location is worse than one that admits the gap.

I built the whole thing solo: the data model, the Supabase backend with row-level security, the Prisma-typed access layer, the media pipeline that takes 6000×4000 camera files down to responsive derivatives, the public site, and the admin CMS the photographer actually uses. The brief was that after handover he should never need me, a terminal, or a redeploy to publish a bird.

What I built

  • Designed the taxonomic data model — subject, frames, chapter, common/scientific/Bengali naming — as the spine of both the public site and the CMS.
  • Built a custom admin CMS for subjects, frames, films, journal entries, licensing tiers and site copy, with draft/publish states and image reordering.
  • Implemented Supabase Postgres with row-level security so the public role can read only published rows and the editor role owns everything else, enforced in the database rather than in application code.
  • Modelled the schema in Prisma with versioned migrations, giving end-to-end type safety from column to React component.
  • Built the media pipeline on Supabase Storage: derivative generation, AVIF/WebP with responsive srcsets, blur placeholders and EXIF extraction on upload.
  • Built full-text search across common, scientific and Bengali names using Postgres tsvector, so a search works whichever name the visitor knows.
  • Built the licensing surface — personal, commercial and fine-art print tiers priced per frame — feeding a typed enquiry flow.
  • Built the contact and enquiry system with Zod-validated Server Actions, a honeypot field, rate limiting and transactional email delivery.
  • Integrated the YouTube Data API for the films index, cached server-side so the page never waits on a third-party quota.
  • Implemented ISR with tag-based revalidation so a publish in the CMS refreshes exactly the affected pages within seconds, without rebuilding the site.
  • Implemented structured data, per-page metadata, sitemap and RSS so individual species pages are indexable and citable.
  • Deployed on Vercel with preview deployments per branch and CI running typecheck, lint and tests before anything ships.

Engineering breakdown

The work that mattered, as problem, solution and result. Shipped is merged and live; Staged is built and tested but intentionally off by default pending cutover; Blueprint is designed and documented, awaiting sign-off.

01

Modelling the archive

Subject as the primary entity

shipped
Problem
The first cut modelled photographs as the top-level record with names attached to each one. Three frames of the same egret meant the species name, binomial and Bengali name were stored three times — and a later re-identification would have had to be applied frame by frame.
Solution
Inverted the model: a subject owns its naming and chapter, and frames are filed beneath it with only per-frame data of their own. Reference codes are issued per subject, not per file.
Result
50 subjects across 87 frames, with 21 subjects holding multiple frames. A re-identification is a single row edit, and the archive reads as a catalogue rather than a feed.

Nullable field record, honest empty states

shipped
Problem
Most frames predate any intention to publish, so locality and date were never written down. A schema requiring them would have forced either fabricated values or an unpublishable archive.
Solution
Made every field-record column nullable and gave each one an explicit rendered empty state — "not recorded" — rather than hiding the field or estimating it.
Result
The archive publishes what is actually known and says so where it does not, which is the only version of it that is usable as a reference.

Names in three systems

shipped
Problem
Visitors search for the same bird as a Great Egret, as Ardea alba, or by its Bengali name. Three separate lookups chained with fallbacks would have been slow and would have ranked results inconsistently.
Solution
Built a single Postgres tsvector across all name fields with prefix matching for binomials, indexed with GIN and queried in one statement.
Result
One query answers all three naming systems, with consistent ranking and no application-side fallback chain.
02

Handing over the keys

A custom CMS instead of a headless one

shipped
Problem
Off-the-shelf headless CMSs model posts and collections. This content is a taxonomy with frames filed under subjects, per-image licence pricing, and a metadata set that is deliberately half-empty — all of which fights a generic content model.
Solution
Built the admin as part of the same Next.js application, over the same Prisma schema, with draft and published states, drag-to-reorder frames, and inline editing of site copy.
Result
The photographer publishes a new species, a journal note or a price change without a developer, a terminal or a deploy — the brief the project was accepted on.

Authorization pushed into the database

shipped
Problem
Draft species and unpublished journal notes share an application with the public site. A missing filter in any one query would leak unfinished work.
Solution
Wrote Supabase row-level security policies so the public role can select only published rows, and write access is scoped to the authenticated editor.
Result
Unpublished content is unreachable by construction rather than by discipline, and the leak class is eliminated instead of guarded against.

Publish without rebuild

shipped
Problem
Statically rendering an archive gives the best possible load times, but a full rebuild on every edit makes publishing feel like a deployment, which is exactly what the handover was meant to avoid.
Solution
Tagged every cached fetch by subject, chapter and collection, and revalidated precisely those tags from the CMS write path.
Result
An edit is visible within seconds, only the affected pages regenerate, and the site keeps static delivery.
03

Serving big photographs to small connections

Derivatives from full-resolution originals

shipped
Problem
Source frames are 6000×4000 files. Serving anything close to them would make the archive unusable on the mobile connections most of its audience is on, but downscaling the stored asset would destroy the thing being licensed.
Solution
Kept originals untouched in Supabase Storage and generated AVIF and WebP derivatives at responsive widths on upload, with blur placeholders for the loading state and EXIF pulled for whatever the camera recorded.
Result
Grid and detail pages serve kilobytes while the licensable original remains intact and private behind signed URLs.

Films indexed, not re-hosted

shipped
Problem
Fifteen field films needed to appear on the site, but self-hosting video would have meant bandwidth cost and a second media pipeline for no benefit — and the audience already subscribes on YouTube.
Solution
Indexed the channel through the YouTube Data API with thumbnails and titles cached server-side, linking out to play.
Result
The films page renders from local data, survives an API quota or outage by serving stale content, and keeps viewers counted where the channel is.
04

Enquiries and licensing

Typed enquiries at source

shipped
Problem
Print orders, licensing requests, editorial projects, field days and speaking invitations all arrived as undifferentiated email and had to be triaged by hand.
Solution
Made enquiry type a required field on the form, validated with Zod inside a Server Action, and delivered transactionally with the type carried through to the subject line and the stored record.
Result
Requests arrive pre-classified, and the enquiry table doubles as a lightweight record of demand per category.

Per-frame licence pricing in the CMS

shipped
Problem
Personal, commercial and fine-art print prices were hardcoded, so any change was a code change — and pricing moves far more often than the software does.
Solution
Moved the three tiers into the schema as per-subject fields, editable in the admin and rendered directly on the species page.
Result
Prices are visible to buyers up front and adjustable by the photographer without touching the repository.

Spam handling without a captcha

shipped
Problem
A public, indexed contact form attracts automated submissions, but a captcha taxes every genuine enquiry to stop them.
Solution
Added a hidden honeypot field that only automated clients complete, server-side Zod validation, and per-IP rate limiting.
Result
Automated submissions are rejected silently, and a real visitor never encounters an obstacle.

Through-line

The record is the product

Every structural decision — subject-first modelling, three naming systems, reference codes, nullable metadata — exists so the site works as a reference someone can cite, not just a portfolio someone can admire.

Never guess on the client's behalf

Where locality or date was not recorded, the archive says so. Inventing plausible metadata would make the whole catalogue untrustworthy, and a photographer's credibility is the asset the site is built to carry.

Hand over something they can run

The measure of success was that the photographer never needs me again to publish. That set the bar for the CMS: draft states, image reordering, editable copy and pricing, and a publish that takes seconds rather than a deploy.

The CMS · what the photographer manages

  • Subjects & taxonomy
  • Frame uploads & ordering
  • Chapters (Birds, Wildlife, Butterflies, Landscapes)
  • Scientific & Bengali naming
  • Field-record metadata
  • Reference codes
  • Licence tiers & pricing
  • Journal entries
  • Film index
  • Homepage & about copy
  • Testimonials
  • Enquiry inbox
  • Draft / published states
  • Media library
  • SEO metadata per page

Stack

Framework

  • Next.js 15 (App Router)
  • React 19
  • Server Components
  • Server Actions
  • TypeScript

Data

  • Supabase Postgres
  • Prisma ORM
  • Row-level security
  • Postgres full-text search
  • Versioned migrations

Auth & storage

  • Supabase Auth
  • Supabase Storage
  • Signed URLs

Media

  • next/image
  • sharp
  • AVIF / WebP derivatives
  • Blur placeholders
  • EXIF extraction

UI

  • Tailwind CSS
  • shadcn/ui
  • Radix primitives
  • Framer Motion

Forms & validation

  • Zod
  • React Hook Form
  • Honeypot + rate limiting

Integrations

  • YouTube Data API
  • Transactional email
  • Social embeds

Infrastructure

  • Vercel
  • ISR with tag revalidation
  • Edge caching
  • Preview deployments
  • GitHub Actions CI

Quality & SEO

  • ESLint
  • Prettier
  • Playwright
  • JSON-LD structured data
  • Sitemap & RSS
  • Core Web Vitals