A full-stack logistics platform frontend: shipment management, a public
customer-tracking page, an event/webhook delivery pipeline, and an admin
dashboard — built on Next.js 16 (App Router), shadcn/ui, Radix UI,
Tailwind CSS v4, and Motion. It talks to a separate Express/MongoDB
backend in the sibling davinci/ directory.
This project replaces an earlier Vite + React Router SPA that only handled webhook CRUD. The rebuild keeps the same backend contract but moves auth and data-fetching entirely server-side (Next's BFF pattern) and expands scope to the full platform described in the product roadmap: shipments, events, deliveries, public tracking, marketing pages, a demo receiver, and settings.
The projects in Waybridge
and Waybridge-BE are
direct results of the efforts of everyone listed in the CONTRIBUTORS.md
files of both repositories. Both projects also owe their success to the
underlying work in the
Webhook project.
- Faith Gabriel (@Faithgabriel1)
- Egele Tochi Vivian (@Kingsley-Vivian)
- Babs-Alli Ayomipo David (@BabsAlliDev)
- Opeyemi Daodu (@Alternateopeyemi)
- Nnanyerugo Victory (@ivynvc)
- Emmanuel Agbavwe (@Aro1914)
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack, Server Actions, proxy.ts) |
| UI primitives | shadcn/ui (New York style) on top of Radix UI / @base-ui/react |
| Styling | Tailwind CSS v4 (globals.css tokens, no tailwind.config.js) |
| Animation | Motion (motion/react) |
| Forms | react-hook-form + zod (@hookform/resolvers) |
| Toasts | sonner |
| Icons | lucide-react |
| Package manager | pnpm (see packageManager field — use pnpm, not npm/yarn) |
| Backend | Express + MongoDB/Mongoose, JWT auth (separate repo, see below) |
pnpm install
pnpm devOpen http://localhost:3000. The app expects a
running backend (see Backend dependency below) —
without it, every authenticated page will fail its server-side fetch calls.
Create .env (already gitignored):
BACKEND_API_URL=http://localhost:5000/apiBACKEND_API_URL is server-only (read in lib/api-config.ts, never
prefixed NEXT_PUBLIC_*) — all backend requests are made from Server
Components, Server Actions, and Route Handlers, never from the browser. If
you need the browser to know a backend-derived URL (e.g. the demo receiver
URL shown on /demo-receiver), pass it down as a plain prop from a Server
Component; don't expose the base URL itself to the client.
Canonical URLs, social metadata, and the sitemap use NEXT_PUBLIC_SITE_URL.
If unset, it defaults to http://localhost:3000 in development and
https://waybridge-six.vercel.app in production. Set it to the exact site
origin (scheme and host, without a path or query) when deploying on a custom
domain.
Waybridge can be installed through the browser's native install control or, on iOS, Safari's Share → Add to Home Screen action. The installed app opens the dashboard; signed-out users follow the existing login redirect. Production installation requires HTTPS (localhost is treated as secure during development). Waybridge follows the selected light/dark appearance, but it is an online client: shipment data and backend features require a network connection, and no offline cache is provided.
pnpm dev # next dev (Turbopack, port 3000)
pnpm build # next build — also runs the TypeScript checker
pnpm start # next start (serve the production build)
pnpm lint # eslintBefore committing any change, run pnpm build and pnpm lint and confirm
both are clean. This is the standing bar used throughout this project's
history — see Verification workflow.
This frontend has no backend code of its own. It is a pure client of the
Express API defined in the sibling davinci/ directory. To run the
full stack locally:
cd ../davinci
npm run devThe backend needs its own .env configured as described in its README. It
listens on port 5000 by default; this project's default BACKEND_API_URL
targets that port.
The frontend integrates with the merged API contract documented in
../davinci/openapi.yaml and summarized in ../davinci/API_MIGRATION.md.
When the backend is running locally, its interactive API reference is at
http://localhost:5000/docs.
It supports customer-owned shipments, user-owned webhooks, admin-only event
views, delivery summaries and retries, and the demo receiver. Refresh tokens are delivered as
HttpOnly cookies; configure BACKEND_API_URL to the backend origin and use
the documented auth/session flow rather than storing refresh tokens in
browser-accessible storage.
The backend issues registration's access token as data.token; login and
refresh return both data.accessToken and the compatible data.token alias.
Event list/detail endpoints require an admin account; customer shipment,
webhook, and delivery data is scoped to its owner.
The /demo-receiver page is integrated with the backend receiver API. Webhook
POSTs go to the public receiver URL; request inspection, clearing, and response
configuration are admin-only. The page supports request filters, pagination,
signature details, configurable success/failure responses, and reset actions.
History and response profiles are in memory and reset when the backend restarts.
Production deployments must enable the backend route with
ENABLE_DEMO_RECEIVER=true.
If the backend contract changes, consult its migration guide and the corresponding route/controller before adjusting the frontend.
app/
(auth)/ # /login, /signup — public, unauthenticated layout
(marketing)/ # /, /about — public landing pages
(dashboard)/ # authenticated app shell (sidebar + breadcrumbs)
dashboard/ # aggregate stats + recent activity
shipments/ # list, detail, create
webhooks/ # list, detail, create, edit
events/ # list, detail (+ deliveries triggered by the event)
deliveries/ # list, detail (+ attempt history, resend)
demo-receiver/ # demo receiver tools
settings/ # profile info + logout
track/ # /track — public, no-auth customer tracking page
layout.tsx, error.tsx # root layout + root error boundary
globals.css # Tailwind v4 tokens (design system source of truth)
features/ # feature-folder pattern: one dir per domain
auth/ # auth-actions.ts (login/signup/logout Server Actions)
shipments/ # shipment-actions.ts + components/
webhooks/ # webhook-actions.ts + components/
events/ # components/ (read-only, no actions file)
deliveries/ # delivery-actions.ts (resend) + components/
tracking/ # tracking-actions.ts + shipment-timeline.tsx
dashboard/ # stat cards, aggregate panels
lib/ # server-only data layer + shared helpers
api-config.ts # BACKEND_API_URL, DEMO_RECEIVER_URL
server-fetch.ts # authFetch<T>(), ApiRequestError, buildQuery()
session.ts # httpOnly cookie session (create/delete/read)
dal.ts # Data Access Layer: verifySession(), getCurrentUser()
*-service.ts # one per domain: webhook/shipment/event/delivery
validate-*.ts # zod schemas mirroring backend validation rules
format-date.ts, format-event-type.ts
constants/ # single source of truth for enums shared across features
shipment-status.ts # SHIPMENT_STATUSES, VALID_TRANSITIONS, badge variants
webhook-events.ts # derives from shipment-status.ts (no duplication)
delivery-status.ts # DELIVERY_STATUS_BADGE_VARIANT
components/
ui/ # shadcn primitives (generated — see note below)
app-sidebar.tsx, nav-user.tsx # hand-written app-shell chrome
proxy.ts # Next 16's middleware — optimistic cookie-presence
# auth gate (PROTECTED_PREFIXES / AUTH_ONLY_PREFIXES)
Unlike the old SPA (which stored the JWT in localStorage and attached it
via an axios interceptor), this app follows Next 16's BFF (Backend-for-
Frontend) pattern:
- On login/signup, the Server Action in
features/auth/auth-actions.tscalls the backend, receives a JWT, and stores it in an httpOnly cookie (lib/session.ts, cookie namesession_token). - Every subsequent authenticated request reads that cookie server-side
(
lib/dal.ts'sgetAuthHeader()) and attaches it as anAuthorization: Bearer <token>header when calling the backend from a Server Component, Server Action, or Route Handler. - The browser never sees the raw JWT. There is no client-side fetch to the
backend anywhere in this codebase — every data-fetching function in
lib/*-service.tsis annotatedimport "server-only". proxy.ts(formerlymiddleware.tsin older Next versions) does a fast optimistic check — cookie presence only, no backend round-trip — to redirect unauthenticated requests away from protected routes before the page even starts rendering. The real authorization check still happens server-side per request via the backend's own JWT verification; an expired/invalid token simply surfaces as a 401 fromauthFetch.
When adding a new protected route segment, add its top-level path to
PROTECTED_PREFIXES in proxy.ts — it is not automatically inferred from the
(dashboard) route group.
lib/dal.ts is the only place that reads the session cookie directly. Every
lib/*-service.ts file calls getAuthHeader() from there rather than
touching cookies itself. features/tracking/tracking-service.ts is the one
deliberate exception — the backend's tracking endpoint is public
(tracking.routes.js has no protect middleware), so that service
intentionally does not gate on a session.
Every mutation (create*Action, update*Action, delete*Action,
resendDeliveryAction, etc.) lives in a feature's *-actions.ts file with
"use server" at the top, and follows the same shape:
export async function fooAction(input): Promise<ActionResult<T>> {
const parsed = fooSchema.safeParse(input);
if (!parsed.success) return { success: false, message, fieldErrors };
try {
const data = await fooService(parsed.data);
refresh(); // next/cache — re-fetches fresh data in Server Components
return { success: true, data };
} catch (error) {
if (error instanceof ApiRequestError) return { success: false, message: error.message };
return { success: false, message: "Something went wrong. Please try again." };
}
}Client components call these via useTransition + startTransition(async () => ...), never raw <form action={...}> unless the action itself is the whole
form's submit handler (see settings/page.tsx's logout form for the simplest
case, and webhook-form.tsx for the useActionState-free, manual-submit
version used everywhere else).
Every lib/validate-*.ts file is a zod schema hand-copied from the
backend's actual validation rules (Mongoose schema constraints and/or
inline express-validator checks in the controller), not invented
independently. If the backend changes a validation rule, update the matching
zod schema — don't let them drift.
constants/shipment-status.ts is the single source of truth for shipment
statuses, valid status transitions, and badge-variant color mapping. Both
constants/webhook-events.ts (event-type dropdown options) and the shipment
UI derive from it. Don't reintroduce a second copy of the status list
somewhere else — import from here.
Every list page (webhooks, shipments, events, deliveries) follows the
same shape: a client-side filter bar that mutates URL search params
(?search=&status=&page=) via useRouter/useSearchParams +
useTransition, and a Server Component table wrapped in <Suspense key={...} fallback={<TableSkeleton />}> so filter changes show a skeleton instead of a
blank screen while the new page streams in. Copy this pattern for any new
list view rather than inventing a new one.
Kept intentionally light — used for page-transition ((dashboard)/template.tsx's
AnimatePresence) and staggered table-row entrance (*-rows.tsx components,
e.g. webhook-rows.tsx, shipment-rows.tsx) and animated stat-counter numbers
(features/dashboard/components/stat-count.tsx). If you add new Variants
objects with string-literal ease values (e.g. "easeOut"), explicitly
type them as Variants (imported from motion/react) — otherwise
TypeScript widens ease to string, which doesn't satisfy Motion's
Easing union and fails the build.
z.coerce.number()breakszodResolver+ react-hook-form's generic inference in the current zod/RHF combo — it causes aResolver<...>type mismatch because the coerced schema's input/output types diverge. Use plainz.number(...)and convert<Input type="number">manually viaonChange={(e) => field.onChange(e.target.valueAsNumber)}instead of spreading{...field}directly (seeshipment-form.tsx).- zod's
ZodError.issues[].pathis typedPropertyKey[](can includesymbol), not(string | number)[]. Any shared error-flattening helper (seeflattenZodErrorsin the*-actions.tsfiles) must type it that way and.map(String).join(".")when building a field key. - The shadcn CLI is not 100% reliable in this environment.
pnpm dlx shadcn@latest add <component>sometimes fails silently without writing a file (this happened forform— it was hand-written instead). Always verify the expected file actually appeared incomponents/ui/after running the CLI rather than assuming success. - Stale
.nexttype cache after deleting a route file: if you delete anapp/**/page.tsxandnext build's TypeScript step errors referencing the deleted file's compiled type declaration, delete the.next/folder and rebuild from scratch. hooks/use-mobile.ts(shadcn-generated) originally violatedreact-hooks/set-state-in-effectby callingsetStatesynchronously inside an effect in addition to the resize listener. It's fixed via a lazyuseStateinitializer — don't regenerate this file via the CLI without reapplying that fix, orpnpm lintwill fail again.- No
tailwind.config.js— this is Tailwind v4, config lives entirely inapp/globals.cssvia@theme. Don't add a v3-style config file. - This project has never been tested against production infrastructure —
only local
pnpm build/pnpm lintand a local backend + MongoDB Atlas cluster. Before deploying, confirm cookiesecure/sameSitesettings inlib/session.tsbehave correctly behind your actual hosting setup (HTTPS termination, custom domains, etc.).
This project has been built and re-verified incrementally, track by track, using the same gate every time:
- Read the actual backend contract (controller + route + model) before writing a data-layer function — never trust the roadmap doc's API shape blindly.
- Implement the data layer (
lib/*-service.ts), validation (lib/validate-*.ts), and Server Actions (features/*/*-actions.ts). - Build the feature's components and route(s).
- Run
pnpm build— must compile and typecheck clean, and the route list in the build output must include every new route. - Run
pnpm lint— must be clean. - For anything touching auth, data mutations, or a new page shape, do a runtime smoke test against a live backend (see below) — a clean build does not guarantee correct runtime behavior (e.g. a Server Component throwing on a 401 mid-render only shows up at runtime, not at build time).
With both the backend (npm run dev in ../davinci/) and this app
(pnpm dev) running:
- Register a user via
POST /api/auth/register, then log in to get a JWT. - Set that JWT as the
session_tokencookie (httpOnly cookies can't be set from browser JS, but can be set directly in a test HTTP client) and hit protected pages directly to confirm they render real data, not just a 200 with an empty/error state. - Create a shipment and a webhook via the backend API, advance the
shipment's status, and confirm: an event was created, a delivery fired
(success or failure depending on the webhook URL), and all of
/shipments,/events,/deliveries, and/dashboardreflect it. As an admin, open/demo-receiver, refresh request history, and inspect the captured payload and signature result. Try a test webhook at the/failURL and update/reset response profiles. - Test the public
/trackpage against a real tracking number. - Clean up any test data created against a shared/real database.
- API key management: referenced in the old SPA's empty
ApiKeysstub, but deferred here because the current backend contract doesn't expose an API key endpoint. Don't invent one — confirm with the backend team first. - Role-based UI gating: the backend enforces
admin/operations-only restrictions on shipment creation and status updates, and role-based delivery visibility. The frontend currently relies on the backend rejecting unauthorized requests (surfaced as a toast) rather than hiding UI elements pre-emptively for non-privileged roles. Consider conditionally hiding "New shipment" / status-update controls based ongetCurrentUser()'srolefield for a cleaner UX.