Client Project

Plant
Database

A project built for a horticulturalist.
A searchable database keeping record of plant types, bloom months, light requirement, soil requirements, and horti-specific information such as pH levels, fragrances, soil types, harvest info, nativity and more..

Case Study

Client Requirements

The client needed a way to search and filter their collection across multiple categories at once. Filters are additive — a plant that blooms in both spring and summer will show up under either, so nothing slips through the cracks.

The app works with or without an internet connection. Changes made offline are saved locally and synced automatically once the connection is restored. Plant photos are stored in the cloud, and each entry can be tailored with whatever details are relevant.

The search tool draws on a botanical database to surface rich information about any plant — common and scientific names, growing conditions, photos, and links to trusted reference sources.

Plants in the personal collection are added by hand, with the owner filling in only the details that matter to them. This keeps the database focused and accurate rather than bloated with data that isn't useful.

User Flow

Start
Add a plant
manuallyvia Trefle

Manual Entry

Fill in plant details, descriptions, images, notes & horticulture traits

POST to Postgres with all selected criteria

Trefle Search

Search by name - hits Trefle API

Browse botanical data — growth habits, native regions, care requirements

One-click save imports Trefle data into personal collection

Plant saved to collection

Returned on /saved with full metadata

API Integration

The /api/plants route proxies requests to the Trefle botanical API, combining plant and species lookups into a single round trip. Server-side caching with 3600-second revalidation reduces upstream calls.

/api/plants/route.ts
async function fetchPlantById(plantId: number): Promise<TreflePlant | null> {
  try {
    const response = await fetch(
      `${BASE_URL}/plants/${plantId}?token=${TREFLE_API_KEY}`,
      { next: { revalidate: 3600 } }
    )
    if (!response.ok) return null
    const data = await response.json()
    return data.data as TreflePlant
  } catch {
    return null
  }
}
/api/plants/route.ts
async function fetchSpeciesDetails(speciesId: number): Promise<TrefleSpecies | null> {
  try {
    const response = await fetch(
      `${BASE_URL}/species/${speciesId}?token=${TREFLE_API_KEY}`,
      { next: { revalidate: 3600 } }
    )
    if (!response.ok) {
      logger.warn({ event: 'species_fetch_failed', speciesId, status: response.status })
      return null
    }
    const data: TrefleSpeciesDetailResponse = await response.json()
    return data.data
  } catch (err) {
    logger.error({ event: 'species_fetch_error', speciesId, error: String(err) })
    return null
  }
}
/api/plants/route.ts
function buildQueryString(params: Record<string, string | undefined>): string {
  const validParams = Object.entries(params)
    .filter(([_, value]) => value !== undefined && value !== '')
    .map(([key, value]) => `${key}=${encodeURIComponent(value!)}`)
    .join('&')
  return validParams ? `?${validParams}` : ''
}

Data Architecture

The application maintains a strict boundary between Trefle API shapes (snake_case) and the app domain model (camelCase). Mapping occurs in exactly two files.

src/types/plant.ts
export interface Plant {
  id: string
  apiId: number | null
  commonName: string
  scientificName: string
  description: string
  plantType: string[]
  lifespan: string[]
  blooms: string[]
  light: string[]
  forHarvest: boolean
  hedgingTopiary: boolean
  native: boolean
  fragrant: string[]
  poison: string[]
  ph: string[]
  watering: string
  soilType: string[]
  images: string[]
  pendingImages: string[]
  notes: string
  isFavourite: boolean
  createdAt: number
  updatedAt: number
  syncStatus: SyncStatus
}
src/types/plant.ts
export interface PlantFilter {
  plantType: string[]
  lifespan: string[]
  blooms: string[]
  light: string[]
  ph: string[]
  poison: string[]
  forHarvest: boolean | null
  hedgingTopiary: boolean | null
  native: boolean | null
  fragrant: string[]
  soilType: string[]
}

Image Hosting

Users can upload images for each plant. Images are sent asynchronously to Cloudinary via next-cloudinary, which returns a URL. That URL is saved to the plant record in Postgres and displayed on the /saved-plants route.

When offline, images queue in a pendingImages IndexedDB table and upload automatically on reconnection. The app runs fully without Cloudinary configured — it falls back to base64 data URLs stored locally.

src/lib/db.ts

export async function uploadImage(file: File): Promise<string> {
  const formData = new FormData()
  formData.append('file', file)
  formData.append('upload_preset', uploadPreset)

  const response = await fetch(
    `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`,
    {
      method: 'POST',
      body: formData,
    }
  )

  if (!response.ok) {
    throw new Error('Failed to upload image')
  }

  const data = await response.json()
  const url: unknown = data.secure_url

  if (typeof url !== 'string' || !url.startsWith('https://')) {
    logger.error({ event: 'cloudinary_invalid_url', receivedType: typeof url })
    throw new Error('Cloudinary returned an invalid URL')
  }

  return url
}  
            

Why Trefle?

Trefle aggregates, normalises, and enriches plant data from multiple botanical and public sources into a single API — USDA plant datasets, GBIF taxonomy records, OpenFarm growing information, and various botanical databases. It handles deduplication across sources, taxonomic normalisation, and data enrichment.

Alternatives — Perenual, Plant.ID, iNaturalist, raw GBIF or WFO — each contain pieces of the puzzle but not the complete picture. Scraping multiple sources independently would be over-engineered for what the search feature needs.

Notably, no existing application combines plant taxonomy with practical horticulture information — pests, watering, soil types, bloom seasons — in a single authoritative source. This remains an identified gap in the market.

Technical

Tech Stack

TypeScript

Type-safe boundary across all layers

React 19

Client rendering · state · context API

Next.js 16

App Router · API proxy · Vercel runtime

Supabase

Postgres · RLS · Auth · browser client

Vercel Analytics

Privacy-friendly usage insights · zero config

Tailwind CSS v4

Utility-first styles · zero runtime cost

Dexie / IndexedDB

Offline-first local persistence + sync FSM

Cloudinary

Async image hosting direct Cloudinary REST API

Architecture

Client

Offline-capable

  • React 19

    UI + Context state

  • Tailwind CSS v4

    Utility-first styles

  • Dexie / IndexedDB

    Local PENDING → SYNCED FSM

  • Lucide React

    Tree-shakeable icons

src/lib/db.ts
const db = new Dexie('PlantDB') as Dexie & {
  plants: EntityTable<Plant, 'id'>
  pendingImages: EntityTable<PendingImage, 'id'>
  pendingDeletions: EntityTable<PendingDeletion, 'plantId'>
}
db.version(1).stores({
  plants: 'id, syncStatus, createdAt',
  pendingImages: 'id, plantId',
})
db.version(2).stores({
  plants: 'id, syncStatus, createdAt',
  pendingImages: 'id, plantId',
  pendingDeletions: 'plantId',
})

Server

Vercel Serverless · Key Isolation

  • Next.js 16 App Router

    Routing + SSR

  • /api/plants

    Trefle proxy + cache headers

  • Input validation

    Query length · ID parsing

  • TypeScript

    Strict typing throughout

src/app/api/plants/route.ts
export async function GET(request: NextRequest) {
  const cid = crypto.randomUUID()
  // plant_id → plant + species in one round trip
  if (plant_id_raw !== null) {
    const plant = await fetchPlantById(plant_id)
    const species = await fetchSpeciesDetails(
      plant.main_species_id
    )
    return NextResponse.json({ data: plant, species })
  }
  // search mode — cached 3600 s
  const response = await fetch(
    `${BASE_URL}/plants/search${buildQueryString(params)}`,
    { next: { revalidate: 3600 } }
  )
  return NextResponse.json(await response.json())
}

External

Third-party data + storage

  • Trefle API

    Botanical catalogue

  • Supabase Postgres

    Persistent user data + RLS

  • Cloudinary CDN

    Optional image hosting

src/lib/supabase/client.ts
browserClient = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  {
    auth: {
      persistSession: true,
      storageKey: AUTH_COOKIE_KEY,
      storage: {
        getItem: getCookie,
        setItem: setCookie,
        removeItem: deleteCookie,
      },
      autoRefreshToken: true,
      detectSessionInUrl: false,
    },
  }
)

Decisions & rationale

01

Next.js App Router with server-side API proxy

The Trefle API key must never reach the client. /api/plants proxies all requests server-side, adds HTTP cache headers, and validates query params - impossible with a pure client-side SPA.

02

Supabase browser client only - no SSR adapter

@supabase/ssr was intentionally removed. The app uses RLS for per-user row isolation. Plain createClient from @supabase/supabase-js does the same job with one fewer dependency.

03

Offline-first persistence with Dexie / IndexedDB

Every plant write is staged locally first with a PENDING | SYNCED | ERROR sync status state machine. Reconnection triggers automatic background sync - no user action required, no data loss.

04

React Context only - no external state library

usePlants is the single source of truth. FilterContext holds shared filter state across pages. Zustand or Redux would add indirection without removing meaningful complexity at this scale.

05

Strict type boundary: Trefle shapes vs. app domain

TrefleSpecies (snake_case API shapes) and Plant (camelCase app interface) are kept entirely separate. Mapping happens in exactly two files - never a third location. Preventing API shapes from leaking into the domain model.

06

Cloudinary image hosting as an optional layer

Plant images queue in a pendingImages IndexedDB table and upload asynchronously to Cloudinary. The app runs fully without Cloudinary configured - falls back to base64 data URLs stored locally.

07

Server-side response caching via revalidate headers

Trefle API responses cached for 3600 seconds. Next.js fetch cache applies automatically - repeated calls from different users hit the cache layer, reducing upstream API calls and latency. Plant + species fetches combined into one round trip.

08

Input validation at the API boundary

Query length capped at 200 characters, positive integer validation for plant_id and species_id. Returns 400 Bad Request with descriptive messages, preventing malformed requests from reaching Trefle. Validators are pure and reusable.

09

Structured logging with correlation IDs

Every log entry requires a typed `event` key enforced at call site. `level` and `timestamp` added automatically. Production emits JSON strings (grep-friendly for aggregators), development pretty-prints objects. Correlation IDs on all API requests enable tracing across distributed calls.

10

Recent searches persisted to localStorage with validation

Search queries stored as a max-5 JSON array. Runtime validation ensures stored values are valid strings - silently discards corrupted or cross-origin writes. Queries trimmed and deduplicated on each add. No external state library needed for this UX pattern.

Capabilities

Key Features

User Authentication

Supabase Auth

Secure login and session management with Supabase Auth. Row-level security enforced at the database layer - each user can only read and write their own plant collection.

Plant Search

Trefle Botanical API

Search the global Trefle catalogue by common name, scientific name, family, or genus. Results include growth habits, native regions, flower colour, soil requirements, and harvest timelines.

Save to Collection

One-action import

Pull any searched plant into your personal collection with Trefle data pre-filled. Supplemental enrichment from POWO, GBIF, and EPPO adds missing fields before you save.

Persistent Data

Offline-first sync

All plant data persists to Supabase per user. Works fully offline via IndexedDB with automatic reconnect sync - no manual steps, no data loss between sessions.

Preview

Live demo