AgriLink Marketplace
Overview
AgriLink is a digital marketplace built to connect smallholder farmers directly with buyers across Nigeria. Farmers list their produce, buyers browse and order, and every transaction is protected by an escrow payment system, funds are held until delivery is confirmed by both parties.
The platform was built as part of the Enyata Community × Interswitch Hackathon, working as a team with a backend engineer and a designer. The goal was to ship a real, working product, not a prototype, within the hackathon window.
My role: Frontend Engineer. I was responsible for the full frontend, architecture, migration from the original Lovable scaffold, state management, API integration, and production build. The project is still under active development as we continue stabilizing the backend and wiring up remaining flows.
The Problem
Smallholder farmers in Nigeria largely sell through middlemen who set the prices and take a significant cut. The farmer gets paid less, the buyer pays more, and trust between both parties is low because there's no accountability layer.
AgriLink addresses this by removing the middleman entirely. Farmers set their own prices. Buyers see exactly who they're buying from and where the produce is coming from. And the escrow system means neither party can be short-changed, payment only moves when delivery is confirmed.
The challenge from a product standpoint: three completely different user types, farmers, buyers, and admins, each with their own workflows, navigation patterns, and permissions, all living in the same application.
Where We Started
The project began as a Lovable-generated Vite + React SPA. Lovable is an AI product builder, useful for rapid prototyping, but not where you want to stay for a production codebase. The generated code had everything in a single router file, no clear separation between concerns, and no meaningful folder structure.
Rather than build on top of that codebase and inherit its limitations, I made the call to migrate the entire frontend to Next.js with the App Router. The UI was preserved, components were extracted and rebuilt cleanly, but the foundation was replaced entirely.
The migration wasn't about rewriting for the sake of it. Next.js's App Router gave us nested role-based layouts, server-side route protection via middleware, and a structure that could actually scale as features were added.
Frontend Architecture
The core architectural decision was using App Router route groups to isolate each user role. Each group gets its own layout.tsx, the farmer gets a sidebar, the buyer gets a bottom nav, the admin gets a full management sidebar, without any manual layout switching in page files.
Route protection lives in a single middleware.tsfile at the root. It reads the auth state from a cookie, checks the user's role, and redirects before any page renders. A farmer trying to access /admin gets bounced back to /farmer, no client-side flicker, no page load.
State Management
State is split cleanly between two layers: Zustand for client state and TanStack Query for server state. These two things should never be mixed.
Auth Store
Holds the logged-in user, token, and isAuthenticated flag. Persists to localStorage and also writes a cookie so the server-side middleware can read it without a round trip.
Cart Store
Tracks items a buyer adds before checkout. Persists across sessions so a buyer doesn't lose their cart on page refresh.
UI Store
Sidebar open/close state and modal visibility. Shared across layout and page components without prop drilling.
Query Layer
Every API domain, listings, orders, payments, users, has its own query file with useQuery and useMutation hooks. Pages import from here, never directly from the service layer.
One specific decision worth calling out: the auth store writes the session to a cookie manually on every login and signup. This is necessary because Next.js middleware runs on the server and can't read localStorage. Without the cookie, middleware would have no way to know who's logged in, and every protected route would redirect to login incorrectly.
API Integration
The backend is a PHP REST API with MySQL, built by the team's backend engineer. Every endpoint returns a consistent { success, message, data }envelope. Rather than unwrapping this shape in every page component, the Axios response interceptor handles it globally, by the time data reaches a query hook, it's already unwrapped.
// Response interceptor, unwrap { success, message, data }
api.interceptors.response.use(
(response) => {
if (response.data && "data" in response.data) {
response.data = response.data.data;
}
return response;
},
(error) => {
const message =
error.response?.data?.error ??
error.response?.data?.message ??
"Something went wrong";
if (error.response?.status === 401) {
// Clear auth + redirect to login
localStorage.removeItem("auth-storage");
document.cookie = "auth-storage=;path=/;max-age=0";
window.location.href = "/auth/login";
}
return Promise.reject(new Error(message));
}
);The services layer is separated from the query layer deliberately. Services contain only Axios calls, no React, no hooks, no side effects. Queries wrap services in useQuery or useMutation. This means when the API shape changes, only the service file needs updating.
During development, the frontend ran entirely on mock data before the backend was ready. A single environment variable, NEXT_PUBLIC_API_READY, controls whether queries hit the real API or serve placeholder data. Flipping it to true activates all live queries at once. No conditional logic scattered across pages.
NEXT_PUBLIC_API_BASE_URL=https://your-api-url.com/api NEXT_PUBLIC_API_READY=true # false = mock data, true = live API
Role-Based Flows
Three distinct user experiences live in the same application. Each has its own layout, navigation, and set of actions.
↳ Farmer Portal
The farmer's world is centered on their listings and incoming orders. The dashboard shows active listings, pending orders, and total earnings at a glance. From the orders page, a farmer can accept or reject an order, accepting locks in the escrow, rejecting triggers a refund. The wallet page shows available balance (released escrow) versus pending balance (held escrow), with a withdraw flow for bank transfer.
↳ Buyer Marketplace
Buyers land on a filterable marketplace, searchable by crop name, farmer name, location, and crop type. Clicking a listing opens a detail page with a quantity stepper and a total price calculator. Hitting "Buy Now" routes to checkout, where the order is placed and payment goes into escrow. The buyer's orders page lets them confirm delivery once they've received their produce, which releases the payment to the farmer.
↳ Admin Dashboard
Admins have a bird's-eye view of the platform, total users, active listings, transaction volume, and pending orders. The users table allows search, role filtering, and suspension. The listings table lets admins approve or suspend any listing. The payments table shows all escrow transactions with the ability to manually release or refund, a safety valve for disputes.
Key Decisions
Design Intent
Lovable (Vite SPA) as the starting point
Engineering Solution
Migrated to Next.js App Router, needed nested layouts per role and server-side route protection. Kept the UI, replaced the foundation.
Design Intent
App Router vs Page Router
Engineering Solution
App Router. Three separate role portals with different layouts and auth rules map naturally to route groups. Page Router would have required manual layout wrapping on every page.
Design Intent
React Context for auth (original Lovable code)
Engineering Solution
Replaced with Zustand. Context re-renders the entire tree on every auth state change. Zustand is selective, only components that subscribe to a specific slice re-render.
Design Intent
Inline mock data in page components
Engineering Solution
Moved to query layer with placeholderData. Pages don't know or care whether data is mock or real, the query hook decides based on the API_READY flag.
Design Intent
Tailwind CSS v3
Engineering Solution
Upgraded to Tailwind v4. CSS-first config means design tokens live in globals.css as native CSS variables, not in a JavaScript config file. Better dark mode support and no separate PostCSS setup.
Design Intent
Generic updateStatus() for order actions
Engineering Solution
Split into dedicated accept(), reject(), confirmDelivery(), each mapping to a specific backend endpoint. Clearer intent, easier to debug, matches the API contract.
Challenges
Migrating from React (Vite) to Next.js
This was the heaviest part of the project. The original Lovable codebase was a standard Vite + React SPA, React Router for navigation, a single context for auth, everything client-side. Moving that to Next.js App Router meant touching virtually every file: replacing Link and useNavigate from React Router with Next.js equivalents, splitting the single DashboardLayout into role-specific layout.tsx files, converting the auth context to Zustand, and making sure every interactive component had "use client" at the top.
What made it manageable was taking it one layer at a time, types first, then lib and stores, then shared components, then layouts, then pages, and running bun dev after each layer to catch breaks early. Trying to migrate everything at once and then debug would have been a nightmare. The step-by-step approach meant each problem was isolated and fixable on its own.
Tailwind CSS v4 Breaking Styles
We were on Tailwind v3 at the start. When we upgraded to v4 mid-build, a chunk of the styling broke silently,things that looked fine in development started rendering incorrectly in production. The root cause was that v4 dropped the JavaScript config file entirely. Colors, fonts, and animations that were previously defined in tailwind.config.ts had to be moved into the CSS file itself using the new @theme block.
The fix wasn't just a config change,it meant going through the globals CSS file and rewriting how every design token was declared. Custom colors like sidebar-background and status colors like success and warning had to be re-expressed as native CSS variables mapped through @theme. Once that was done, shadcn/ui components picked up the tokens correctly and the visual layer was stable again. The lesson: don't upgrade a styling framework mid-project unless you're ready to rewrite the foundation.
CORS Errors After Connecting the Live API
Once the backend was deployed and I swapped in the real API URL, every request the frontend made was blocked. The browser was rejecting responses because the backend wasn't sending the right headers to allow cross-origin requests,the frontend runs on a different domain than the API, so the browser treats every request as potentially unsafe until the server explicitly says otherwise.
This wasn't a frontend fix,CORS is configured on the server, not the client. I flagged it to the backend engineer with the exact headers needed: Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Once those were added to the PHP API's response headers, the requests went through cleanly. It's one of those issues that looks alarming in the browser console but has a straightforward fix once you understand what's actually happening.
Key Screens
A visual walkthrough of the core pages across all three user roles.
↳ Landing Page




↳ Auth, Login & Register


↳ Farmer Portal




↳ Buyer Portal


↳ Admin Portal




Current State
✓ Shipped
Full frontend migration to Next.js. All three role portals, farmer, buyer, admin, built and functional. Auth with role-based routing. All pages wired to TanStack Query with mock data fallback. Live API connected across all domains. Production build passing.
◷ Active Development
End-to-end flow testing across all user journeys. Notification system integration. Payment gateway (Interswitch) wiring. Performance optimization pass. Accessibility audit. Backend stabilization with the full API surface live.
This project is actively being developed in collaboration with the backend engineer. The architecture is stable and the core flows are functional, what remains is hardening, testing, and wiring the final integrations before the submission deadline.
