What Farmora is
Farmora ("From Local Farms to Your Door") is a farm-to-door marketplace. Farmers list what they've harvested and customers buy straight from them. Every order is delivered within three days, with a live tracker. Behind the shop there's an admin side where the platform keeps a 10% commission on each sale.
- Live demo: https://farmora-smoky.vercel.app
- Code: https://github.com/Fehan999/Farmora
Farmora π±
From Local Farms to Your Door
Farmora is a farm-to-table marketplace I built with Next.js. Farmers list what they've harvested, customers buy it straight from them, and every order arrives within three days with a live tracker that follows it from the farm to the front door. Behind that there's an admin side where the platform keeps a 10% commission on each sale. It started as a portfolio project, but I wanted it to behave like a real shop: real sign-ups, a real database, photo uploads for farmers, and pages that still load when the backend is having a bad day.
Live demo: farmora-smoky.vercel.app
Try it
No sign-up and no passwords needed. Open the login page and:
- Pick Customer or Farmer.
- Click View demo account to jump straight in, or Create your own account if you'd rather start fresh.
- Curious about the business side? Click View adminβ¦
I designed and built it on my own. It has 27 page routes plus 6 blog article pages, 40 products in 8 categories, 8 partner farms with full profiles, 12 sample customers and 18 sample orders. All of it is sample data. There are no real farms, customers or payments behind it.
There are three roles:
- Customers search and filter the shop and fill a cart. Delivery is free over $50 and $4.99 otherwise. They can apply coupons and check out with a demo card or cash on delivery.
- Farmers get a dashboard with gross sales, the 10% platform fee, net earnings, a 7-day earnings chart and a product editor with photo upload.
- Admins see platform revenue, a sales table with CSV export, product moderation, user management and a contact inbox. They don't get a cart or "My Orders", because they aren't shoppers.
Why I built it
I wanted a portfolio project that behaves like a real shop, and a marketplace forces questions a single-vendor store never has to answer. Who can see which order? What counts as the platform's revenue? What does a farmer see that an admin doesn't? What happens when the backend is down?
The farm angle gave every product a source, too. Each item shows the farm it came from, and each farm has its own profile page.
Architecture
I kept the stack small on purpose:
- Next.js 14.2 with the App Router and React 18, in plain JSX (no TypeScript)
- Tailwind CSS 3
- Firebase 10 for Authentication, Firestore and Analytics
- Supabase Storage for farmer photo uploads
- Pexels CDN for product photos
- Vercel for hosting
- Playwright and GitHub Actions for screenshots and image checks
Seed farms, products, orders and photo ids live in data/. Most of the code in this post is in lib/.
1. A data layer that never makes the page wait
In the first version, getProducts() started with await readCollection("products"), and the page sat on a loading state until that came back. Now every getter in lib/db.js answers immediately. It merges three sources by id: the seed data, the last Firestore snapshot cached in localStorage, and any changes made in this browser. Firestore only refreshes that data in the background.
Every Firestore call in db.js goes through a timeout, now 4 seconds instead of 6:
const withTimeout = (p, ms = 4000) =>
Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error("timeout"), { code: "timeout" })), ms))]);
remote() returns whatever is cached right now. If that cache is more than a minute old, it also starts a refresh. refresh() does the fetch, but skips it when Firestore is already known to be down, when the collection is locked, or when a request for that collection is already in flight:
function refresh(name) {
if (!isBrowser || remoteDown || deniedCollections.has(name) || inflight.has(name)) return;
const job = (async () => {
try {
const { db, sdk } = await getDb();
const snap = await withTimeout(sdk.getDocs(sdk.collection(db, name)));
const data = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
memory.set(name, { data, at: Date.now() });
local.set(CACHE_PREFIX + name, data);
notify();
} catch (err) {
markFailure(name, err);
} finally {
inflight.delete(name);
}
})();
inflight.set(name, job);
}
function remote(name) {
if (!isBrowser) return [];
let entry = memory.get(name);
if (!entry) {
entry = { data: local.get(CACHE_PREFIX + name), at: 0 };
memory.set(name, entry);
}
if (Date.now() - entry.at > REMOTE_TTL) refresh(name);
return entry.data;
}
getProducts() merges the three sources in one line:
let all = mergeById(PRODUCTS, remote("products"), local.get("farmora_products"))
Failures go one of two ways. A permission-denied on one collection keeps only that collection local. Anything else, like a timeout or a disabled API, marks Firestore as down for the rest of the session, so no other page waits out the same four seconds:
function markFailure(name, err) {
const code = err?.code || "";
if (code === "permission-denied") {
deniedCollections.add(name);
return;
}
remoteDown = true;
try { sessionStorage.setItem(STATUS_KEY, "1"); } catch {}
}
When fresh data arrives, notify() fires a window event:
function notify() {
if (isBrowser) window.dispatchEvent(new Event("farmora:data"));
}
On the React side, components/useData.js listens for that event and re-runs the page's loader in silent mode, so the page updates without a spinner. A request counter drops the result of an older run that finishes late:
const run = useCallback(async (silent) => {
const request = ++requestRef.current;
if (!silent) setLoading(true);
const result = await loaderRef.current();
if (request !== requestRef.current) return;
setData(result);
setLoading(false);
}, deps);
...
useEffect(() => {
const onChange = () => run(true);
window.addEventListener("farmora:data", onChange);
return () => window.removeEventListener("farmora:data", onChange);
}, [run]);
The home page, product pages and farm profiles pass seed data to useData as the initial value, so their first render already has content. Writes follow the same pattern. They're saved to localStorage first, then sent to Firestore in the background.
2. Loading Firebase when the browser is idle
The other cost was bundle size. The first lib/firebase.js imported firebase/auth and firebase/firestore at the top of the file, so every page shipped the SDK whether it used it or not. In the build output, that was about half of the shared JavaScript on every page.
Now each service is a cached promise around a dynamic import():
export function getDb() {
if (!dbPromise) {
dbPromise = Promise.all([getFirebaseApp(), import("firebase/firestore")]).then(([app, sdk]) => ({
db: sdk.getFirestore(app),
sdk,
}));
}
return dbPromise;
}
The auth listener in context/AuthContext.jsx doesn't start until the browser is idle:
const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 200));
idle(start);
start prefetches the products, users and orders collections, starts Analytics and subscribes to onAuthStateChanged. The saved session in localStorage is read before any of that, so a returning user sees their name in the navbar straight away.
Shared first-load JS per page went from about 215 KB to about 106 KB.
Supabase got the same treatment. getSupabase() in lib/supabase.js is a lazy import("@supabase/supabase-js"), so the client only downloads when a farmer uploads a photo.
3. Commission math in one place
The business rule is short. Farmora keeps 10% of the product subtotal (price Γ quantity) of every order that wasn't cancelled. Delivery fees and tax don't count. An order with $42.00 of produce earns the platform $4.20 and the farmer $37.80.
My first admin dashboard got this wrong. Its "Total revenue" card added up full order totals, delivery and tax included. The platform never keeps that money, so the biggest number on the page was misleading.
Now the rate lives in one constant in lib/format.js:
export const COMMISSION_RATE = 0.1;
export const itemsSubtotal = (items = []) => items.reduce((s, i) => s + i.price * i.qty, 0);
export const commissionOf = (amount) => amount * COMMISSION_RATE;
And lib/stats.js builds every order-level number from it:
export const FEE_PCT = Math.round(COMMISSION_RATE * 100);
export const PAYOUT_PCT = 100 - FEE_PCT;
export const isActiveOrder = (o) => o.status !== "cancelled";
export const orderGross = (o) => itemsSubtotal(o.items || []);
export const orderCommission = (o) => commissionOf(orderGross(o));
export const orderPayout = (o) => orderGross(o) - orderCommission(o);
The admin overview, the 7-day commission chart, commission by category, the Sales table, its CSV export and the farmer dashboard all read from these two files, so the numbers always agree. Even the labels use FEE_PCT. Change the rate and the "Commission (10%)" column header changes with it.
The admin side still shows sales volume and farmer payouts, labelled as context so nobody mistakes them for revenue.
4. A delivery tracker with no background job
Every order moves through five stages over 72 hours. I didn't want a cron job or a cloud function moving statuses along, so lib/delivery.js works out the stage from the order's timestamp:
export const DELIVERY_HOURS = 72;
export const STAGES = [
{ key: "placed", label: "Order Placed", at: 0, icon: "π" },
{ key: "packed", label: "Packed at Farm", at: 6, icon: "π¦" },
{ key: "shipped", label: "Shipped", at: 24, icon: "π" },
{ key: "out", label: "Out for Delivery", at: 60, icon: "π΅" },
{ key: "delivered", label: "Delivered", at: 72, icon: "π‘" },
];
export function deliveryStatus(order, now = Date.now()) {
...
const created = new Date(order.createdAt).getTime();
const hours = Math.max(0, (now - created) / 36e5);
const percent = Math.min(100, (hours / DELIVERY_HOURS) * 100);
let stageIndex = 0;
STAGES.forEach((s, i) => { if (hours >= s.at) stageIndex = i; });
...
}
The DeliveryTracker component updates now every 30 seconds, which moves the progress bar and the countdown.
Because it's plain math on a timestamp, no server has to keep it current. Sample orders work too, since the seed creates them relative to the current time. And the public /track page can show any order's progress from its order number, with no login. Customers can cancel until the order ships, and each order has a printable invoice.
The trade-off is that a stage isn't a real event. A real delivery system would need scans from the farm and the courier, and the timestamp math would only be the estimate shown until those scans arrive.
5. Images: srcset, fallbacks, and a workflow that checks every photo
The first version used placeholder images from a random-image service. They were slow and often showed the wrong food, so I replaced them with 125 curated Pexels photos.
data/photos.js stores the Pexels ids. lib/images.js builds a srcset from each URL, so a phone never downloads a desktop-sized photo:
const WIDTHS = [320, 480, 640, 960, 1280];
...
export function srcSetFor(src) {
if (!isPexels(src) || !/[?&]w=\d+/.test(src)) return undefined;
return WIDTHS.map((w) => `${resize(src, w)} ${w}w`).join(", ");
}
SafeImage wraps every product photo. It lazy-loads unless the image is marked priority, which the hero images are. When a photo fails, it falls back in two steps: first to an optional fallbackSrc, then to an inline SVG placeholder.
function onError() {
setStage((s) => {
const from = s === 0 && !src ? 1 : s;
return from === 0 && fallbackSrc ? 1 : 2;
});
}
Fallbacks hide broken images from me as well as from visitors. So I added a GitHub Action that builds the site, starts it and runs scripts/screenshots.mjs with Playwright.
The script screenshots every page, with the right demo session in place where a page needs one. It also requests every photo URL in data/photos.js. A photo passes only on a 200 response whose content type, if there is one, is an image:
entry.ok = entry.status === 200 && (!entry.contentType || entry.contentType.startsWith("image/"));
The results go to image-report.json. The script also renders contact sheets: grids of every photo, each captioned with the product or page it belongs to, with failures outlined in red. The workflow uploads all of it as an artifact and pushes the screenshots to a separate screenshots branch.
The URL check caught 10 broken photo URLs.
The contact sheets caught something a status code never will: several photos that loaded fine but didn't match their product. When I couldn't find a matching photo, I renamed the product rather than show a misleading picture. "Rainbow Carrots" became "Farm Fresh Carrots".
6. SEO with JSON-LD and generated OG images
A pageMeta() helper in lib/seo.js gives each public page its own title, description, canonical URL and Open Graph/Twitter tags.
The root layout adds a JSON-LD graph with WebSite (including a SearchAction that points at /shop?q=), Organization, Person for me as the author, and SoftwareSourceCode linking to the repo.
Product pages get a Product graph with offers, price, stock, delivery time and rating, plus a breadcrumb list. The product page itself is a client component, so the JSON-LD goes in a server layout that looks the product up in the seed data:
export default function ProductLayout({ children, params }) {
const { product, farmer, category } = lookup(params.id);
return (
<>
{product && <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: toJsonLd(productJsonLd(product, farmer, category)) }} />}
{children}
</>
);
}
toJsonLd escapes <, so a product description can never close the script tag early:
export const toJsonLd = (data) => JSON.stringify(data).replace(/</g, "\\u003c");
Product pages start from seed data, so the h1, price and description are in the server-rendered HTML too. Blog articles get BlogPosting data. app/sitemap.js lists every product, farm and blog post, and app/robots.js tells crawlers to skip the cart, checkout, orders, account, wishlist and dashboards. There's also a web manifest and a humans.txt.
The share image is generated with next/og. app/opengraph-image.jsx returns a 1200Γ630 ImageResponse with the logo, the tagline, my name and the stack. There's no separate design file to keep in sync with the site.
7. Demo accounts for visitors
Nobody reviewing a portfolio wants to sign up just to see a dashboard. The login page asks one question: Customer or Farmer? Then you can click "View demo customer account" (or the farmer version), or create your own account with Firebase email/password. A separate "View admin demo" option opens the business side.
The demo login doesn't wait on Firebase. It writes a session and tells the auth listener to ignore Firebase for it:
async function demoLogin(role) {
const acc = DEMO_ACCOUNTS[role];
const session = {
uid: `demo-${role}`, id: `demo-${role}`, email: acc.email, name: acc.name,
role: acc.role, farmerId: acc.farmerId || `demo-${role}`, demo: true,
};
ignoreFirebase.current = true;
setUser(session);
...
return session;
}
The demo button also prefetches its destination route as soon as it renders.
The demo farmer is Robert Hayes from Green Valley Farm. His dashboard opens with products, orders and earnings already in it.
How I tested it
Besides the screenshot workflow, I crawled every route with Playwright at desktop and mobile widths. No route had page errors or horizontal overflow. I checked 79 internal links and ran the main flows end to end: each demo login, checkout through to the tracker, and the admin access rules. The layout holds up on phones down to 390 px wide.
Lessons learned
A timeout doesn't help if the page still waits for it. The first version had a timeout and a fallback to sample data. Every page still sat through six seconds before it got to use that fallback. Rendering from local data first, and treating the network as an update, is what fixed it.
Read the build output before optimizing. I only found out Firebase was half of every page's JavaScript because I looked at the numbers. Moving it behind dynamic imports was a small change with a big result.
Write down what "revenue" means before building the dashboard. My first admin overview led with the wrong number because I hadn't defined what the platform keeps. One constant and a few small helpers fixed it.
A 200 status doesn't mean the image is right. The HTTP check found the broken URLs. Only the contact sheets showed me the photos of the wrong food.
Make the demo the front door. For a portfolio project, a one-click demo account matters more than any feature hidden behind a sign-up form.
Limitations and what's next
Checkout is a demo. No real payments happen, and the test card 4242 4242 4242 4242 charges nothing.
Next on my list:
- Real payments with Stripe
- Moving the background sync to Firestore real-time listeners, and tightening the security rules
- Delivery slots per farm, instead of one 72-hour timeline for everyone
- A proper end-to-end test suite (the screenshot script already does half the clicking)
Try it
Open the login page, pick a role and click the demo button.
A few other things to try:
- Track a sample order without signing in at /track. Try
ORD-10241. - Use the coupons
FRESH10,FARMORA20andWELCOME5at checkout.
If something breaks, or you'd have built the data layer differently, leave a comment here or open an issue on GitHub. I'd like to hear it.





Top comments (0)