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
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.
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
}
}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
}
}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.
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
}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.
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
Type-safe boundary across all layers
Client rendering · state · context API
App Router · API proxy · Vercel runtime
Postgres · RLS · Auth · browser client
Privacy-friendly usage insights · zero config
Utility-first styles · zero runtime cost
Offline-first local persistence + sync FSM
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
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
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 AuthSecure 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 APISearch 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 importPull 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 syncAll 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