PlantPath
A multi-tenant SaaS for small-scale plant breeders and seed savers to track lineages, crosses, and trials across seasons.
- Stack — Next.js 15, TypeScript, tRPC, Prisma, Postgres (Neon), Clerk, shadcn/ui, Tailwind, Vercel
- Role — Solo, full-stack
- Status — In planning
The problem
Small-scale plant breeders and seed savers — hobbyists working with peppers, tomatoes, vegetables, ornamentals — track their work in spreadsheets, paper notebooks, or scattered apps. The fundamental mismatch is that breeding is graph-shaped: every plant has parents, every cross produces offspring, and the interesting questions are about lineage and inheritance. Spreadsheets are tabular.
Three generations in, "which parent produced this F2 seed, and how did its siblings perform?" becomes a manual archaeology project across multiple files. The information exists, but it's not queryable.
PlantPath is the tool I want for that work.
Why this project
This is a portfolio piece first and a hobby-driven project second. I wanted it to be real enough that someone could pick it up and use it, not just another todo app. Plant breeding is a domain I have enough background in to design a credible tool for, and it's full of motivated hobbyists keeping records badly. Halfway-decent tooling actually helps.
The deeper reason is that I want to know whether I can scope and ship a SaaS solo. My capstone project, Babyseeders , was group work where I wore the PM hat. PlantPath is just me, the codebase, and the question of whether the discipline holds up without the team structure.
The data model
This is where most of the design work is happening. The core entities:
- Workspace — the tenant. Every domain row is scoped to one. Owns its members, plants, seasons, and trials.
- WorkspaceMember — the user-to-workspace join, carrying a role (
owner | editor | viewer). This is where permissions actually live. - Plant — an individual grown specimen. Has a generation marker (F0/F1/F2/Fn) and a
PlantStatus(active/dormant/dead/archived) that is deliberately separate from adeletedAtsoft-delete column — real-world state and "do I want to see this in my list" are different questions. - PlantParent — the parentage edge table. Each row links a child Plant to a parent Plant with a role:
SEED,POLLEN,SELF, orUNKNOWN. Zero, one, or two rows per child handles founders, selfings, crosses, and partially-known lineage uniformly. - CrossPollination — a recorded pollination event between two parent Plants (or a selfing). When seeds from that cross are later planted, the resulting child Plants get their PlantParent edges populated from it.
- Season — a time window (Spring 2026, Fall 2026) that groups Plants and Trials temporally. Breeding is cyclical, and "what did I plant in 2024?" is a real query.
- Event — sow, germinate, transplant, harvest, death. Time-stamped observations on a Plant, with optional metrics and photos.
- Trial — a structured experiment: a hypothesis, a set of conditions, a set of Plants, and outcomes recorded against them.
The interesting technical piece is that lineage queries are recursive. "Show me everything descended from this Plant" is a graph traversal, not a join. Postgres handles this cleanly with WITH RECURSIVE CTEs, which is the reason for Postgres specifically — and the reason PlantParent lands in the schema in Phase 1 even though the UI to populate it doesn't ship until Phase 3. Adding the edge table later would mean migrating every plant query.
The cascade rules on PlantParent are deliberately asymmetric: child deletes cascade (incoming edges to a deleted child are meaningless), parent deletes are restricted (you can't hard-delete a Plant that's a parent of another Plant without orphaning a descendant's lineage). That restriction is what forces — and justifies — the soft-delete column.
Stack decisions
Next.js 15 + TypeScript on Vercel — what I already know well, full-stack in one repo, deployment story is solved.
tRPC — end-to-end type safety with no schema drift between frontend and backend. This is a direct response to Babyseeders, where shortchanged API contracts cost us real time. With tRPC, the contract is the implementation.
Prisma + Postgres on Neon — Prisma's generated types pair naturally with tRPC; Postgres handles the recursive lineage queries that the data model demands. Neon over Supabase specifically because I'm using Clerk for auth and deferring file storage — Supabase's bundled features aren't paying for themselves here, and Neon's database branching maps onto Vercel's preview deployments cleanly (every PR can get an isolated DB).
Clerk for auth — first time using it, but only for user identity: signup, login, sessions, password reset. Workspaces, members, roles, and invitations are modeled in my own Postgres schema rather than going through Clerk Organizations. The multi-tenancy implementation is a primary thing I want to be able to talk about, and "I configured Clerk Organizations" is a much weaker story than walking someone through the membership table, the tRPC middleware that enforces roles, and the invitation token flow. Trades roughly an extra week of build time for a stronger narrative and looser vendor coupling.
shadcn/ui + Tailwind for components — also first time. The shadcn model (you own the source for each component, copy-pasted into your repo) is the opposite of the typical component library tradeoff, and customization stays tractable as the design evolves.
Multi-tenancy
Workspaces are the unit of tenancy. A user belongs to one or more workspaces via WorkspaceMember, which carries a role of owner, editor, or viewer. Every domain row — plants, crosses, seasons, trials — is scoped by workspaceId, and the pattern is shared-database / shared-schema / row-level scoping. Schema-per-tenant is overkill for this scale.
Enforcement lives in tRPC middleware. Two layered procedures: workspaceProcedure looks up the calling user's WorkspaceMember row for the requested workspace and throws FORBIDDEN if it's missing; editorProcedure composes that and rejects when the role is viewer. Every workspace-scoped router goes through one of them. The client is never trusted to assert what it has access to.
The deliberate piece is keeping all of this out of Clerk Organizations and in my own schema. It makes joins simple, keeps permission checks fast and indexed, and means the multi-tenancy story is mine to tell rather than a config screen.
What's next
Still in planning. Phase 1 is the smallest end-to-end loop that exercises every layer of the stack:
- Sign up via Clerk; the webhook mirrors the user into the local
Usertable - Create a Workspace (transactionally, with the OWNER membership row)
- Add, edit, and soft-delete a Plant inside that workspace
- See the active plants on a workspace dashboard
Crucially, the genealogy edge table (PlantParent) and soft-delete column ship in Phase 1 even though the UI to populate them doesn't — both are expensive to retrofit later. The lineage UI, cross-recording flow, and family tree visualization land in Phase 3, after multi-tenancy and collaboration in Phase 2. The point of the first slice is to prove the architecture — auth, multi-tenancy middleware, soft-delete, the deployment pipeline — before scaling features on top of it.
What I'm hoping to learn
Solo shipping discipline is the main one. With Babyseeders, momentum came from the team — deadlines, sync points, someone else expecting your code. With PlantPath, I'm the only person who knows whether I worked on it this week. That's the muscle I want to build.
The secondary goals are concrete: get fluent with Clerk, with shadcn, and with tighter tRPC patterns than I've used before. None of those are revolutionary on their own; the point is to build the kind of stack fluency you only get from shipping a real project on it.