A modern full-stack van rental platform built with React Router 8, showcasing advanced web development techniques including server-side rendering, authentication, and responsive design.
- Features
- Tech Stack
- Project Structure
- Database
- Authentication
- URL State Management
- SEO & Routing
- React 19 Features
- Getting Started
- Environment Variables
- Scripts
- Styling
- Code Quality
- Deployment
- Contributing
- 🚀 Modern React Router 8 with server-side rendering and file-based routing
- 🔒 Authentication with better-auth (sign up, login, session management, safe
redirectToreturn URLs, per-field form errors) - 📤 Shared form actions (
FormActionResult,StatusButton, fetcher status helpers) for pending/success/error submit UX across auth and host forms - 🧱 Typed service results (
ServiceResult+DomainError→toActionResultOrThrow) map host rentals/wallet failures tobadRequest/conflict/internalError/notFound - ⚛️ React 19 (canary) & Compiler (Activity component, native meta elements, automatic optimizations, lazy loading)
- 🚌 Van Management (CRUD operations, van types, image handling, state management, SEO-friendly slug URLs)
- 🔍 Advanced Van Filtering (modular filter panel, facet-based state filters,
useVanFiltershook, multi-select types, optimistic UI, debounced nuqs updates) - 📱 Mobile Navigation (Base UI Dialog drawer, animated hamburger, slide-in overlay)
- 🖼️ Image Optimization (WebP format, responsive images, quality compression, modern formats)
- 💸 Rental System (rent, return, and manage van rentals)
- ⭐ Review System (rate and review rentals with analytics)
- 📈 Host Dashboard (modular sections — income, reviews, vans, wallet — with bar charts and rental analytics)
- 💰 Financial Management (deposit/withdraw funds, transaction tracking with pagination)
- 🏷️ Van State System (NEW, IN_REPAIR, ON_SALE, AVAILABLE with discount pricing)
- 💲 Dynamic Pricing (discount system with strikethrough original prices)
- 🎨 Modern UI/UX with responsive design, custom Tailwind variants, and smooth animations
- 🧑💻 TypeScript throughout with strict type checking
- 🧪 ArkType for runtime schema validation and type-safe narrowing
- 🗄️ Time-sortable database IDs with UUID v7 (text columns on D1/SQLite)
- 🎨 TailwindCSS 4 with modern CSS features
- 📦 Drizzle ORM with Cloudflare D1 (SQLite) and relational queries
- 🔧 Generic Components for reusability and maintainability
- 🎭 Higher-Order Components (HOCs) for component enhancement and DRY principles
- 🧩 Compound Components with React 19's modern context API (no
.Provider, usesuse()) - 📊 Sortable Data Tables with reusable sorting components
- 📱 Responsive Design with mobile-first approach
- ⚡ Performance Optimized with deferred loader promises (
DeferredAwait/DeferredPaginated), lazy charts, code splitting, and immutable array methods - 🧊 HTTP cache headers —
PRIVATE_NO_STOREfor host/auth;PUBLIC_SHORT_CACHE+Vary: Cookiefor catalog; leafheadersexports viaforwardDataHeaders - 🔗 URL State Management with nuqs 2.9.5 for type-safe search parameters
- 🌐 View Transitions for smooth navigation (auth login/sign-up, host income/transfers/dashboard amounts, sortable titles, chart pages, footer, and form field morphs; no nested
viewTransitionNames) - 🎯 Middleware-Driven Headers (automatic header forwarding via React Router 8 middleware)
- 🔄 Shared Context Middleware for eliminating duplicate data fetching between loaders and actions
- 🔐 Consolidated host auth middleware on
host-layout.tsx(no duplicate session lookups on leaf routes) - 🔍 SEO Infrastructure (canonical URLs, Open Graph/Twitter meta,
robots.txt, dynamicsitemap.xmlvia@forge42/seo-tools) - ☁️ Cloudflare Workers deployment with Varlock-managed secrets, D1 binding (
env.DB), and Workers Cache enabled
- React canary builds with stable Activity component for prerendering
- React Router 8.3.0 (file-based routing, SSR, optional route parameters, middleware)
- TypeScript 7.0.2 with strict configuration
- TailwindCSS 4.3.3 with modern CSS features
- @base-ui/react 1.7.0 + shadcn/ui (
base-novastyle) for dialog, popover, checkbox, label, and mobile nav - Lucide React 1.28.0 for icons (direct imports for performance)
- Recharts 3.10.1 for data visualization (lazy-loaded; SSR-safe without
.client) - nuqs 2.9.5 for type-safe URL state management via shared parsers
- Cloudflare Workers with React Router SSR via
workers/app.ts - Drizzle ORM 1.0.0-rc.4 with Cloudflare D1 (SQLite;
drizzle-orm/d1) - better-auth 1.7.0-rc.3 with @better-auth/drizzle-adapter (
relations-v2) for authentication - ArkType 2.2.3 for schema validation and type narrowing
- uuidv7 for app-generated IDs (
createId/uuidv7PrimaryKeyhelpers) - Varlock for typed, validated environment variables (Bitwarden integration in production)
- Vite 8.2.0 - Rolldown-based tooling; native
resolve.tsconfigPathsfor~/imports - @vitejs/devtools 0.4.12 - Vite DevTools + DevTools for Rolldown (client/ssr environments)
- rollup-plugin-visualizer 7.0.1 - Client/server bundle treemaps (
VITE_ANALYZE=true) - @fontsource-variable/inter - Self-hosted Inter (latin variable subset, ~48KB)
- React Compiler 1.0 (stable) - Automatic memoization via
@rolldown/plugin-babel+reactCompilerPreset - Biome 2.5.6 for linting and formatting with Ultracite integration
- Ultracite 7.10.1 - AI-friendly linting rules for maximum type safety and accessibility
- Varlock 1.16.0 - Typed env schema (
.env.schema) with Cloudflare integration - Wrangler 4.118.0 - Cloudflare Workers CLI for deploy, D1 migrations, and typegen
- drizzle-kit 1.0.0-rc.4 - Schema migrations (
d1-httpremote;drizzle.local.config.tsfor local Studio) - react-doctor 0.9.5 - React diagnostics in CI, locally, lint-staged, and via Cursor post-edit hook (
.cursor/hooks/react-doctor.mjs) - fallow 3.14.0 - Code health, dead code, duplication, complexity, architecture boundaries (
.fallowrc.jsonc) - Husky 9.1.7 for Git hooks and pre-commit automation with lint-staged
- TypeScript 7.0.2 (native
tsc; VS Codejs/ts.experimental.useTsgooptional) - Bun for fast package management and runtime
- Vite 8.2.0 - Rolldown pipeline,
build.target: "esnext",server.forwardConsole - React Compiler -
@rolldown/plugin-babel+reactCompilerPreset()from@vitejs/plugin-react(import preset only — notreact(); seedocs/babel-react-compiler.md) - Automatic optimizations - React Compiler handles memoization without manual
useMemo/useCallback - Path aliases - Native Vite
resolve.tsconfigPaths(novite-tsconfig-pathsplugin) - Bundle analysis - Per-environment Rolldown plugins: visualizer →
build/client|server/stats.html; DevTools capture →build/devtools/whenVITE_ANALYZE=true - Type-safe configuration - Full TypeScript support in Vite config
app/
├── components/ # Reusable UI components
│ ├── ui/ # shadcn base-nova (@base-ui/react): button, dialog, popover, checkbox, badge, etc.
│ │ # Variant tokens in button-variants.ts, badge-variants.ts
│ ├── form/ # Field, FormError, FormActionResult types, fetcher status → StatusButton helpers
│ ├── deferred-*.tsx # DeferredAwait / DeferredItems / DeferredPaginated (Suspense + Await)
│ ├── status-button.tsx # Pending/success/error submit button (idle auto-reset via useAutoIdleStatus)
│ ├── types.ts # Shared prop types (AsProps, EmptyState, ErrorState, ViewTransitionTune)
│ └── [common] # Generic components (lists, sortable, etc.)
├── constants/ # App-wide constants (cache-headers, time-constants, enums)
├── dal/ # Global data access helpers
│ ├── schemas.server.ts # Shared UUID v7 ArkType schema (branded)
│ └── parse-uuidv7.server.ts # Parse/string → UUIDv7 at trust boundaries
├── features/
│ ├── auth/
│ │ ├── schemas.server.ts # Login/sign-up ArkType schemas
│ │ └── types.ts # Login/sign-up field keys + field-error types
│ ├── host/
│ │ ├── components/ # Host UI (van-form, charts, dashboard sections, reviews)
│ │ │ └── dashboard/ # host-income-section, host-review-section, host-vans-section, host-wallet-form
│ │ ├── dal/ # Host Drizzle repositories (*.server.ts)
│ │ ├── services/ # dashboard, income, rental, reviews, transfers, wallet
│ │ ├── hooks/ # use-host-wallet, balance-reducer
│ │ ├── rentals/
│ │ │ └── schemas.server.ts # Rental action schemas
│ │ ├── schemas.server.ts # Host action schemas (deposit/withdraw)
│ │ └── utils/ # Chart period/points, pickChartGranularity, resolveChartContext
│ ├── image/ # Image optimization utilities
│ ├── middleware/ # Auth, Cloudflare, db context, auth-redirect helpers
│ ├── navigation/ # Nav, mobile-nav (Base UI Dialog), hamburger-icon
│ ├── pagination/ # Shared pagination UI + utils (toPagination, getCursorMetadata, resolveSortedCursor, build-search-params)
│ ├── seo/ # SEO helpers (canonical URLs, SeoHead, sitemap)
│ │ └── dal/ # SEO Drizzle reads (sitemap.server.ts)
│ └── vans/
│ ├── components/ # Van UI (VanCard, VanDetail, HostVanDetail*, van-filters/, etc.)
│ │ └── van-filters/ # VanFilters, type/state sections, facet config, shared filter types
│ ├── constants/ # Van-related constants (van-types.ts for client-safe constants)
│ ├── dal/ # Van Drizzle repositories (*.server.ts)
│ ├── services/ # catalog, host-vans, van-detail
│ ├── hooks/ # use-van-filters, host-vans list reducer, display hooks, optimistic filter hooks
│ ├── schemas.server.ts # Van form/search ArkType schemas
│ ├── types.ts # Van-specific TypeScript types (incl. VanFormValues / field errors)
│ └── utils/ # pricing, van-filter-url, to-van-form-values, pending-van-from-form-data
├── db/ # Drizzle schema, client, seed, migrations
│ ├── client.server.ts # createDb(d1) → drizzle-orm/d1
│ ├── d1-http.server.ts # Remote D1 HTTP (`/raw`) for seed; tryCatch + split helpers
│ ├── migrations/ # SQL migrations (flattened for Wrangler D1)
│ ├── schema/ # auth.ts, van.ts, index.ts
│ ├── seed-data/ # Modular seed data files
│ ├── seed.ts # Local + remote seed entry
│ └── relations.ts # Drizzle relations
├── hooks/ # Custom React hooks
├── lib/ # Server-side utilities
│ ├── auth.server.ts # Better-auth + drizzle-adapter/relations-v2
│ ├── env.server.ts # Varlock env re-export
│ ├── id.server.ts # UUID v7 ID generator for Better Auth
│ ├── parsers.ts # nuqs search parameter parsers
│ ├── search-params.server.ts # Server-side search param loaders
│ └── generic-sorting.server.ts # Generic Drizzle orderBy utilities
├── types/ # Shared utility types (Maybe, List, Id, Prettify, Replace, Search)
│ ├── auth.server.ts # AuthenticatedUser (UUIDv7 id)
│ └── ids.server.ts # UUIDv7 re-export from dal schemas
├── routes/ # Route modules (Activity-based single routes)
│ ├── api/ # better-auth handler (auth.ts)
│ ├── auth/ # login, sign-up, sign-out
│ ├── host/ # Dashboard, income, transfers, reviews, vans, rentals
│ │ └── rentals/ # rentals list, rent/:vanSlug, returnRental/:rentId
│ ├── layout/ # Layout components
│ └── public/ # Public routes
│ ├── vans.tsx # Van listing
│ ├── van-detail.tsx # Van detail page
│ ├── home.tsx # Home page
│ ├── about.tsx # About page
│ ├── robots.txt.ts # Dynamic robots.txt
│ ├── sitemap.xml.ts # Dynamic sitemap
│ └── 404.tsx # Not found page
├── utils/ # Shared utilities
│ └── errors/ # tryCatch, ServiceResult, DomainError, toActionResultOrThrow, HTTP helpers
├── assets/ # Static assets (SVGs, images)
├── root.tsx # Root component
└── routes.ts # Route configuration
workers/
└── app.ts # Cloudflare Workers entry (React Router SSR)
docs/
├── d1-setup.md # Cloudflare D1 create/migrate/seed guide
├── react-router-audit.md # Framework-mode audit and middleware notes
├── babel-react-compiler.md # React Compiler via @rolldown/plugin-babel (Vite 8)
├── react-stinky-report.md # React Stinky smell sweep + fixes
└── fallow-health-backlog.md # Code health backlog from fallow analysis
.fallowrc.jsonc # Fallow config (boundaries, health thresholds, security categories)
- Cloudflare D1 (SQLite) with Drizzle ORM (
drizzle-orm/d1) - Schema in
app/db/schema/(auth.ts,van.ts); remote viadrizzle.config.ts(d1-http); local Studio viadrizzle.local.config.ts(Miniflare SQLite) - Setup guide:
docs/d1-setup.md - Main tables:
user,session,account,verification— Authentication (better-auth)van— Listings with types (SIMPLE, LUXURY, RUGGED), states (IN_REPAIR, ON_SALE, AVAILABLE), SEO slugsrent— Rental records and historyreview— User reviews and ratingstransaction— Financial ledger (deposits, withdrawals, rental payments)
- Account (better-auth 1.7):
issuer+providerAccountId(unique together); credential rows useissuer = local:credential - Features:
- UUID v7 primary keys via
uuidv7PrimaryKey/createId - Drizzle relations v2 (
defineRelationsinapp/db/relations.ts; passed todrizzle(d1, { relations })) - Van search — case-insensitive
LIKEon name/description (word-split) - Indexes for host/type composites, rent pagination (
renterId/rentedTo/id), review FKs; uniquevan.slug - Van state — NEW is client-derived; IN_REPAIR / ON_SALE / AVAILABLE stored
- Slug-based routing with ArkType regex validation
- Branded UUID v7 types via ArkType (
#UUIDv7) andparseUuidV7at trust boundaries dbContextmiddleware — sharesAppDbfromenv.DBwith loaders/actions
- UUID v7 primary keys via
# Create D1 DB once (see docs/d1-setup.md), then:
# Generate SQL migrations from schema
bun run db:generate
# Apply locally (Miniflare) or remotely
bun run db:migrate:local
bun run db:migrate:remote
# Seed (needs ≥3 users via sign-up first)
bun run db:seed # local
bun run db:seed:remote # remote D1 HTTP
# Optional: run SQLite PRAGMA optimize after heavy seed/migrate
bun run db:optimize:local
bun run db:optimize:remote
# Drizzle Studio (local Miniflare SQLite / remote D1 HTTP)
bun run db:studio:local
bun run db:studio:remote// drizzle.config.ts (remote d1-http)
export default defineConfig({
dialect: "sqlite",
driver: "d1-http",
schema: ["./app/db/schema/auth.ts", "./app/db/schema/van.ts"],
out: "./app/db/migrations",
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
databaseId: process.env.CLOUDFLARE_DATABASE_ID ?? "",
token: process.env.CLOUDFLARE_D1_TOKEN ?? "",
},
});Notes:
- Runtime uses
createDb(env.DB)— noDATABASE_URL. - Remote seed uses
createD1HttpDb(app/db/d1-http.server.ts): sqlite-proxy → Cloudflare D1/raw;tryCatchon fetch; helpers for parse, success assert, and row shaping (keeps fallow CRAP under threshold). CLOUDFLARE_*vars required in.env.schema(drizzle-kit Studio / remote seed).- Local Studio:
drizzle.local.config.tsresolves Miniflare SQLite under.wrangler/state/.... - Nested drizzle-kit folders flattened by
scripts/flatten-d1-migrations.tsbefore Wrangler apply;snapshot.jsonkept nested for nextdb:generatediffs. - Hyperdrive is not used (Postgres/MySQL only).
app/dal/— global UUID branding and parsing only (parseUuidV7throwsDomainErrorINVALID_ID)features/*/dal/*.server.ts— Drizzle repositories (persistence, notryCatch)features/*/services/*.server.ts— use-case orchestration; returnServiceResult(ok/err) for mutating flows;tryCatchwhere UI tolerates partial failure- Routes — HTTP only: auth, form validation, call services, map via
toActionResultOrThrow(orthrowDomainHttpin loaders/middleware)
// Route action
const result = await completeReturnRental(db, args);
const actionFailure = toActionResultOrThrow(result);
if (actionFailure) {
return actionFailure;
}
// Service (typed failure kinds)
return err({ kind: "insufficient_funds", message: "Cannot afford…" });- Van validators (
app/features/vans/utils/validators.ts) — VanType / VanState guards - Pagination validators (
app/features/pagination/utils/validators.ts) — limit, direction, sort, cursor - Shared UUID schema (
app/dal/schemas.server.ts) —uuidv7Schema - Server-side ArkType schemas in feature
schemas.server.tsfiles withapp/utils/parse-arktype.server.ts(validateArkType,arkErrorsToFieldErrorsfor per-field form UI)
- better-auth 1.7.0-rc.3 with @better-auth/drizzle-adapter/relations-v2 (SQLite / D1)
- Joins via
advanced.database.joins: true(session/user fetched with relational queries) - Account identity keyed by
(issuer, providerAccountId)— not legacyaccountId - Session management with cookie cache +
preserveSessionInDatabase - Protected routes with automatic redirects via
getLoginRedirectUrl/getSafeRedirectPath(app/features/middleware/utils/auth-redirect.ts) - Return path from React Router’s normalized middleware
url(getReturnPathFromUrl) — strips.data/_.data/_routes(do not use rawrequest.url) - Host auth middleware runs once on
host-layout.tsx(stub loader ensures.datarequests on client navigations) redirectToquery param on login — returns users to the page they tried to visit (open-redirect safe)- ArkType validation (
app/features/auth/schemas.server.ts) for login/sign-up forms - Per-field errors —
arkErrorsToFieldErrors+LOGIN_FORM_FIELDS/SIGN_UP_FORM_FIELDS(app/features/auth/types.ts); UI via sharedField/FormError - Form action results —
FormActionResult+toActionResultOrThrowmapServiceResultkinds tobadRequest/conflict/internalError/notFound;getFetcherStatus+useAutoIdleStatusdriveStatusButton - Accessible auth forms —
useFetcher+useTransition, labeled inputs,aria-invalid/aria-describedby, form-levelrole="alert" - View transitions on login/sign-up — named
viewTransitionNameon card, title, fields, submit, footer with CSS morph animations - Server-side session handling in loaders
- Better-auth config in
app/lib/auth.server.ts;AuthenticatedUsertype inapp/types/auth.server.ts - UUID v7 generator (
createIdinapp/lib/id.server.ts) for user IDs via Better AuthgenerateId
The application uses nuqs 2.9.5 for type-safe URL state management:
- Type-safe search parameters with shared parsers between server and client
- Server-side loaders with
createLoaderfor efficient data fetching - Client-side state management with
useQueryStates - Bidirectional cursor pagination with forward/backward navigation
- Pagination with sorting on Reviews, Income, and Transfers pages
- Van search functionality with case-insensitive
LIKEacross name and description (word-split), debounced input (250ms), immediate Enter key submission - Advanced van filtering via
vansFilterUrlParsers— multi-select types plus facet-driven state filters (van-state-filter-config.ts); debounced adds, immediate removes (van-filter-url.ts) - Automatic URL synchronization with proper type handling
- View transitions support for smooth navigation
- Pagination state preservation - All search params (cursor, limit, types, excludeInRepair, onlyOnSale, search) preserved when navigating to detail pages and back via
buildVanSearchParamsutility
// Shared parsers (app/lib/parsers.ts)
export const paginationParsers = {
cursor: parseAsString.withDefault(DEFAULT_CURSOR),
limit: parseAsNumberLiteral(LIMITS).withDefault(DEFAULT_LIMIT),
direction: parseAsStringEnum(DIRECTIONS).withDefault(DEFAULT_DIRECTION),
type: parseAsVanType,
};
// Server-side loaders (app/lib/search-params.server.ts)
export const loadSearchParams = createLoader(paginationParsers);
// Client-side usage
const [{ cursor, limit, direction, type }, setSearchParams] =
useQueryStates(paginationParsers);
// Preserve pagination and filter state in detail pages
export async function loader({ params, request }: Route.LoaderArgs) {
const { cursor, limit } = loadPaginationParams(request);
const { search } = loadSearchParams(request);
const { types, excludeInRepair, onlyOnSale } = loadVanFiltersParams(request);
// ... fetch data
return data({ van, cursor, limit, search, types, excludeInRepair, onlyOnSale });
}
// Build back link with preserved params (all filters included)
const backLink = buildVanSearchParams({
cursor,
limit,
types,
excludeInRepair,
onlyOnSale,
search,
baseUrl: href('/vans')
});React Router 8's middleware system enables efficient data sharing between loaders and actions:
- Eliminates duplicate fetching - Data fetched once in middleware, shared between loader and action
- Type-safe context - Fully typed shared data with TypeScript
- Cleaner code - Loaders and actions focus on business logic, not data fetching
- Better performance - Reduces database queries and API calls
import { createContext } from 'react-router';
// Define typed context
type SharedData = {
rent: NonNullable<Awaited<ReturnType<typeof getRent>>>;
balance: number;
};
const sharedDataContext = createContext<SharedData>();
// Fetch data once in middleware
const fetchDataMiddleware: Route.MiddlewareFunction = async (
{ params, context },
next
) => {
const [rent, balance] = await Promise.all([
getRent(params.rentId),
getBalance(session.user.id),
]);
context.set(sharedDataContext, { rent, balance });
return next();
};
export const middleware = [authMiddleware, fetchDataMiddleware];
// Synchronous loader - just retrieves from context
export function loader({ context }: Route.LoaderArgs) {
return context.get(sharedDataContext);
}
// Action also uses same data
export async function action({ context }: Route.ActionArgs) {
const { rent, balance } = context.get(sharedDataContext);
// Use shared data for validation/business logic
}Note: Loaders can be synchronous when only retrieving data from context (no await needed).
The application uses human-readable slugs for van URLs and a centralized SEO layer:
SeoHeadcomponent (app/features/seo/seo-head.tsx) - title, description, canonical, Open Graph, and Twitter meta- Server-side SEO builders (
build-page-seo.server.ts) - per-route title/description/canonical URLs SITE_URLenv var - canonical and OG link base (falls back to request origin)- Dynamic
robots.txt- production allows public routes, blocks host/auth/api; dev disallows all - Dynamic
sitemap.xml- lists public van detail pages from database @forge42/seo-tools- robots.txt generation
- SEO-friendly URLs -
/vans/modest-explorerinstead of/vans/cmgg0wp450001zrijvbpx2uo0 - User-friendly - Shareable, memorable URLs for better user experience
- Type-safe validation - ArkType schema with regex validation
- Automatic generation - Slugs auto-generated from van names using
getSlug()utility - Unique constraint - Database-enforced uniqueness with indexed lookups
- Internal ID usage - Database operations use UUID v7 for security and referential integrity
// Slug schema with built-in regex validation (1-70 chars, no leading/trailing hyphens)
export const slugSchema = type("/^[a-z0-9](?:[a-z0-9-]{0,68}[a-z0-9])?$/");
// Database lookup by slug (Drizzle)
const [row] = await db
.select({ id: van.id })
.from(van)
.where(eq(van.slug, vanSlug))
.limit(1);
// Routes use slugs
route(":vanSlug", "./routes/public/van-detail.tsx");- Public van detail:
/vans/modest-explorer - Public van detail with pagination:
/vans/modest-explorer?cursor=abc123&type=luxury - Host van detail:
/host/vans/beach-bum - Rent van:
/host/rentals/rent/the-cruiser
When navigating from a paginated list to a detail page, all search params (cursor, limit, types, excludeInRepair, onlyOnSale, search) are preserved in the URL and automatically included in the back link via the buildVanSearchParams utility. This ensures users return to the exact same filtered and paginated view they were viewing, maintaining complete filter state across navigation.
The application features a comprehensive van state management system with dynamic pricing:
- NEW - Client-derived state for vans created within the last 6 months
- IN_REPAIR - Vans currently under maintenance (not rentable)
- ON_SALE - Vans with discount pricing applied
- AVAILABLE - Standard rentable vans
- Discount System - ON_SALE vans can have 5-100% discounts
- Price Display - Original price with strikethrough, discounted price highlighted
- VanPrice Component - Reusable component for consistent pricing display
- Smart Badges - VanBadge component shows relevant state information
- Client-side Derivation - NEW state computed from createdAt timestamp
// Van state + discount (Drizzle sqliteTable)
state: text("state", {
enum: ["IN_REPAIR", "ON_SALE", "AVAILABLE"],
}).default("AVAILABLE"),
discount: integer("discount").default(0),
// Dynamic pricing component
<VanPrice van={{ price, discount, state }} />- Flexible pricing - Easy to manage sales and promotions
- State consistency - Prevents renting of unavailable vans
- User experience - Clear visual indicators for van status
- Maintainable - Centralized pricing logic in reusable components
The application features a reusable sorting system with type-safe generic utilities:
- Generic sorting utility (
app/lib/generic-sorting.server.ts) for Drizzle orderBy clauses - Reusable Sortable component (
app/components/sortable.tsx) for consistent UI - Type-safe orderBy clauses with full TypeScript support
- URL state integration with nuqs for persistent sorting preferences
- Four sort options: newest, oldest, highest, lowest
// Generic sorting utility
export function createGenericOrderBy(
sort: SortOption,
config: SortConfig
): OrderByClause {
// Returns { field: 'asc' | 'desc' } for Drizzle
}
// Reusable component
<Sortable
title="Reviews"
itemCount={reviews.length}
/>
// Database integration
const orderBy = createGenericOrderBy(sort, {
dateField: 'createdAt',
valueField: 'rating'
});- Reviews page: Sort by newest/oldest date or highest/lowest rating (with pagination)
- Income page: Sort by newest/oldest date or highest/lowest amount (with pagination)
- Transfers page: Sort by newest/oldest date or highest/lowest amount (with pagination)
- Extensible: Easy to add sorting to any new data table
When navigating backward through paginated, sorted results, the sort order is automatically reversed to fetch the correct items. The reverseSortOption helper ensures proper bidirectional pagination:
newest↔oldest(for date-based sorting)highest↔lowest(for value-based sorting)
Results are then reversed back to the correct display order by the toPagination utility.
The application features generic pagination utilities for consistent cursor-based pagination across all data tables:
- Generic
toPaginationutility (app/features/pagination/utils/to-pagination.server.ts) - Processes database results and returns items with pagination metadata getCursorMetadatautility (app/features/pagination/utils/get-cursor-metadata.server.ts) - ProvidescursorId, sort order, andtakefor Drizzlelt/gt+limitqueriesresolveSortedCursorhelper (app/features/pagination/utils/resolve-sorted-cursor.server.ts) - Shared cursor +orderByprelude for host income/reviews/transfers DALs- Bidirectional pagination support - Handles both forward and backward pagination with correct logic
- Automatic result reversal - Reverses results for backward pagination to maintain correct display order
- Type-safe - Full TypeScript support with generic types
reverseSortOptionhelper (app/features/pagination/utils/reverse-sort-order.ts) - Reverses sort options for backward pagination queriesbuildVanSearchParamsutility (app/features/pagination/utils/build-search-params.ts) - Builds URL search parameters for pagination and filter state preservation (supports types array, excludeInRepair, onlyOnSale, search params)
// Get cursor metadata for Drizzle queries
const { cursorId, orderBy, take } = getCursorMetadata({
cursor,
limit,
direction,
});
// Generic pagination utility
export function toPagination<T extends Id>({
items,
limit,
cursor,
direction = 'forward',
}: ToPaginationParams<T>): PaginationProps<T> {
// Processes results, detects extra item, reverses for backward nav
}
// Usage in loaders — apply cursorId with lt/gt on id + limit(take)
const rawItems = await db
.select()
.from(review)
.where(/* cursorId ? lt/gt(review.id, cursorId) : undefined */)
.orderBy(/* from orderBy */)
.limit(take);
const { items, paginationMetadata } = toPagination({
items: rawItems,
limit,
cursor,
direction,
});The toPagination utility implements correct cursor pagination logic:
- Forward pagination:
hasNextPage = hasMoreResults,hasPreviousPage = has cursor - Backward pagination:
hasNextPage = has cursor,hasPreviousPage = hasMoreResults - Result reversal: For backward pagination, results are automatically reversed since the query returns them in opposite order
- Pagination metadata: Returns
paginationMetadataobject withhasNextPageandhasPreviousPageflags instead of separate props
- Consistent pagination - Same logic used across all paginated pages (Reviews, Income, Transfers, Vans)
- Correct bidirectional navigation - Proper handling of forward/backward pagination
- Type safety - Generic utility works with any data type
- Maintainability - Single source of truth for pagination logic
Reusable HOCs for component enhancement and DRY principles:
withVanCardStyles HOC encapsulates common van card styling logic:
// Create styled component
const StyledCard = withVanCardStyles(Card);
// Use with van data
<StyledCard van={van} className="custom-classes">
{children}
</StyledCard>Features:
- Automatic van state styling (NEW, ON_SALE, IN_REPAIR, AVAILABLE)
- View transition names for smooth animations
- Data attributes for CSS-driven child visibility (e.g., VanBadge)
- Group class for Tailwind parent selectors
- Type-safe with full TypeScript support
Benefits:
- DRY: Common styling logic in one place
- Consistency: All van cards styled identically
- Maintainability: Single source of truth for van card behavior
- Type-safe: Proper TypeScript generics and constraints
Used in: VanCard, VanDetail, HostVanDetail compound component
VanDetailCard uses the compound component pattern with React 19's modern context API:
// Usage with sub-components
<VanDetailCard van={van}>
<Activity mode={isDetailsPage ? 'visible' : 'hidden'}>
<VanDetailCard.Details />
</Activity>
<Activity mode={isPhotosPage ? 'visible' : 'hidden'}>
<VanDetailCard.Photos />
</Activity>
<Activity mode={isPricingPage ? 'visible' : 'hidden'}>
<VanDetailCard.Pricing />
</Activity>
</VanDetailCard>Benefits:
- Cleaner API: No prop drilling, van data shared via context
- Modern React 19: Uses
use()hook and context without.Provider - Composable: Mix and match sub-components as needed
- Type-safe: Full TypeScript support with proper error boundaries
The application leverages React 19's modern features for better performance and developer experience:
React 19's stable Activity component enables instant navigation by prerendering multiple views:
import { Activity } from "react";
export default function Vans({ params }) {
const isDetailPage = params.vanSlug !== undefined;
return (
<>
<Activity mode={isDetailPage ? "visible" : "hidden"}>
<VanDetail />
</Activity>
<Activity mode={isDetailPage ? "hidden" : "visible"}>
<VanList />
</Activity>
</>
);
}Benefits: Zero perceived latency between views, state preservation (scroll position, filters), memory efficient with paused effects.
Meta tags use React 19 native elements and the shared SeoHead component for full SEO coverage:
import { SeoHead } from '~/features/seo/seo-head';
export default function Home() {
return (
<section>
<SeoHead title="Home | Van Life" description="..." url={canonicalUrl} />
{/* rest of component */}
</section>
);
}This replaces the deprecated meta export pattern and removes the need for the <Meta /> component in root.tsx.
The application uses React Compiler 1.0 for automatic performance optimizations:
// React Compiler automatically optimizes components
export default function MyComponent({ items }) {
// No manual useMemo/useCallback needed
const filtered = items.filter((item) => item.active);
return <List items={filtered} />;
}Benefits: Automatic memoization, reduced boilerplate, better performance without manual optimization.
React 19's useOptimistic hook provides instant visual feedback for user interactions, particularly useful for filter toggles and search:
import { useOptimistic } from 'react';
const [optimisticValue, toggleOptimistic] = useOptimistic(
initialValue,
reducer
);
// Immediate UI update, actual state update debounced
toggleOptimistic({ type: 'toggle' });Benefits: Instant feedback with lower opacity indicators, reduced perceived latency, improved UX with debounced server updates.
Heavy components like charts are code-split using React.lazy() and Suspense via LazyBarChart → bar-chart.tsx (SSR-safe; no .client suffix required after Varlock/Vite TLA fixes):
const BarChartComponent = lazy(() => import("./bar-chart"));
<Suspense fallback={<Skeleton />}>
<BarChartComponent data={chartData} />
</Suspense>;Host income / reviews / transfers return critical summary data immediately and defer paginated lists via promises. UI wraps with DeferredAwait / DeferredPaginated + route skeletons (IncomeListSkeleton, ReviewListSkeleton, PaginatedItemsSkeleton).
<DeferredPaginated
Component={Income}
fallback={<IncomeListSkeleton />}
resolve={pagePromise}
renderProps={renderIncomeItemProps}
/>Chart series use server SQL aggregations (resolveChartContext, pickChartGranularity, period/points helpers) so clients receive buckets — not raw transaction rows.
- Better Performance - Faster TTFB via deferred lists, smaller payloads, automatic optimizations
- Improved SEO - Proper meta tags, social sharing support
- Simpler Code - Native elements, automatic memoization, no manual optimization
- Enhanced UX - Skeletons while deferred promises resolve; smooth view transitions
- Node.js 24+ (or Bun)
- Bun (recommended)
- Cloudflare account + D1 database (see
docs/d1-setup.md) - Bitwarden access token (optional; for Varlock secret resolution in production)
# Clone the repository
git clone <repository-url>
cd van-life
# Install dependencies
bun install
# Environment: edit .env.schema defaults or add .env.local (gitignored)
# Secrets resolve via Varlock; Bitwarden optional in production
# Required: BETTER_AUTH_SECRET, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_DATABASE_ID, CLOUDFLARE_D1_TOKEN (see .env.schema)
# Set up D1 (create DB + paste database_id into wrangler.jsonc first)
bun run db:generate
bun run db:migrate:local
# Sign up ≥3 users in the app, then:
bun run db:seed
# Start development server (Varlock loads env; Vite CF plugin provides env.DB)
bun run devThe app will be available at http://localhost:5173.
# Build
bun run build
# Preview locally
bun run preview
# Deploy to Cloudflare Workers
bun run deploy:projectEnvironment variables are defined in .env.schema (Varlock) and validated at runtime. Committed env files:
.env.schema— schema + non-secret defaults.env.bitwarden— Bitwarden plugin init +bitwarden()resolvers (imported whenVARLOCK_ENVisdevelopment|preview|production).env.test— plain placeholders for CI /VARLOCK_ENV=test(no Bitwarden)
Use .env.local (gitignored) for local overrides. Bitwarden machine token via env / GH secret BITWARDEN_ACCESS_TOKEN.
# Environment (development | preview | production | test)
VARLOCK_ENV=development
# Authentication (resolved via Bitwarden when not test)
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:5173
# SEO (canonical URLs, Open Graph)
SITE_URL=http://localhost:5173
# drizzle-kit d1-http / remote seed (required in .env.schema)
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_DATABASE_ID=
CLOUDFLARE_D1_TOKEN=
# Bitwarden (required for non-test envs)
# BITWARDEN_ACCESS_TOKEN=Validated and typed via Varlock (.env.schema → env.d.ts); consumed in app code through app/lib/env.server.ts. Runtime DB is the Wrangler D1 binding env.DB (not a connection string).
bun run dev– Start development server with HMR (Varlock loads env; Vite DevTools dock available)bun run build– Build for production (Cloudflare Workers + client assets)bun run analyze– Production build with bundle analysis (VITE_ANALYZE=true; visualizer + DevTools capture)bun run devtools– Open standalone Vite DevTools UI (vite-devtools)bun run preview– Preview the production build locallybun run deploy:project– Deploy to Cloudflare Workers via Varlock + Wrangler (upload + go live)bun run deploy:upload– Upload a Worker version only (preview URL; prod traffic unchanged)bun run deploy:versions– Promote an uploaded version to traffic (interactive; supports gradual %)bun run typegen– Generate Wrangler types and React Router route typesbun run typecheck– TypeScript checking (typegen+tsc)bun run db:generate– Generate Drizzle SQL migrations toapp/db/migrationsbun run db:migrate:local– Flatten + apply D1 migrations locallybun run db:migrate:remote– Flatten + apply D1 migrations remotelybun run db:optimize:local–PRAGMA optimizeon local Miniflare D1bun run db:optimize:remote–PRAGMA optimizeon remote D1bun run db:seed– Seed local Miniflare D1bun run db:seed:remote– Seed remote D1 via HTTP APIbun run db:studio:local– Drizzle Studio against local Miniflare SQLitebun run db:studio:remote– Drizzle Studio against remote D1 (d1-http)bun run fix– Auto-fix issues with Ultracite (format + lint)bun run check– Run Ultracite checks (no fix)bun run doctor– Run Ultracite doctorbun run react-doctor– Run React Doctor diagnostics (doctor.config.ts)bun run fallow– Full fallow analysis (dead code + dupes + health)bun run fallow:audit– PR-style audit (dead code, complexity, duplication on changed files)bun run test– Run Bun test suitebun run prepare– Install Husky hooksbun run ultracite:upgrade– Upgrade Ultracite and re-init (Bun, Biome, Cursor)
bunx ultracite init– Initialize Ultracite in your projectbun run fix– Format and fix code via Ultracitebun run check– Check for issues without fixing
This project uses Husky with lint-staged for automated pre-commit checks:
- Pre-commit hook (
bunx lint-staged) runs automatically before each commit - lint-staged runs Ultracite, react-doctor, typecheck, tests, and fallow on staged files
- Automatic formatting with Ultracite on staged files
- Commit blocking if any checks fail
The pre-commit hook ensures code quality by:
- Running
bun fix(Ultracite) on staged files via lint-staged - Running
react-doctor --stagedon JS/TS files - Running
bun typecheckandbun teston TypeScript files - Running
fallow dead-code --file …on staged TS/TSX files (boundaries + dead code) - Blocking the commit if any step fails
Configuration in lint-staged.config.ts.
Note: TypeScript config files work seamlessly with Bun's first-class TypeScript support. For Node.js, requires version 22.6.0+ or the --experimental-strip-types flag.
- TailwindCSS 4.3.3 with modern features (container queries, view transitions, scroll-driven animations, CSS containment)
- Inter font via
@fontsource-variable/inter(latin variable woff2 only) - Mobile nav animations — overlay fade and slide-in/out (
app/app.css) - Reusable keyframes — parameterized
--fade/--scale/--slide-x/--slide-ywith CSS custom properties - Auth + host view transitions — login/sign-up, income/balance/elapsed-days, sortable-title, and chart-page morphs (
::view-transition-old/newinapp/app.css); avoid nested named elements (breaks one-direction morphs) - Scroll-driven host nav hint —
mask-scroll-hintutility withanimation-timeline: scroll(x self) - Responsive design with mobile-first approach and CSS Grid layouts
- Biome configuration for CSS at-rules support
- Component variants using
cva(button-variants.ts,badge-variants.ts) for consistent UI - Custom Tailwind variants for van states (
van-new,van-sale,van-repair,van-available) - Centralized styling utilities -
getVanStateStyles()function provides consistent styling across all van components - Type-safe styling with TypeScript support throughout
- Utility-first approach with custom CSS utilities for specific needs
- CSS custom properties for dynamic theming and reusable values
bg-skeletonshimmer utility for deferred-list / chart skeletons- Pseudo-random heights using CSS trigonometric functions for skeleton loaders
- Biome 2.5.6 for linting and formatting with Ultracite integration
- Ultracite 7.10.1 - AI-friendly linting rules for maximum type safety and accessibility
- TypeScript 7.0.2 with strict configuration
- ArkType 2.2.3 for runtime validation with regex support for slug validation
- Consistent code style:
- Tab indentation
- Single quotes
- Sorted CSS classes
- Organized imports
- Type safety throughout the application
- Error handling with
DomainError/ServiceResult/toActionResultOrThrow, plusnotFound/serverError/badRequest/conflict/internalError,getRouteErrorMessagefor boundaries, andgetCollectionStatefor list empty/error states - nuqs for type-safe URL state management
- Drizzle with typed schema in
app/db/schema/ - Feature-specific validators - Validators organized by feature domain (vans, pagination) for better maintainability and code organization
- fallow 3.14.0 - Architecture boundaries (feature↔route pairing in
.fallowrc.jsonc), dead-code/dupes/health analysis; rules atwarnuntil backlog cleared - Bun
overrides— pin transitive audit fixes (@remix-run/server-runtime,brace-expansion,fast-uri,picomatch,turbo-stream, …) whilebunfig.tomlkeepsminimumReleaseAge
- CI (
.github/workflows/ci.yml) — least-privilege permissions:- Quality (
contents: read) —VARLOCK_ENV=testloads.env.test(no Bitwarden); Bun install, Ultracitecheck,typecheck,test - Varlock (
contents: read,pushtomasteronly) —VARLOCK_ENV=developmentloads.env.bitwarden+BITWARDEN_ACCESS_TOKEN - React Doctor (PR only;
pull-requests/issues/statuses: write) — self-contained Action, no Bun install - Fallow (PR only;
pull-requests: write,checks: write) — SHA-pinnedfallow-rs/fallow@v3.6.0Action (CLI package isfallow3.14.0); audit + health score + PR summary/review comments + Check Run; security scan (soft gate,fail-on-issues: false)
- Quality (
- CodeQL (
.github/workflows/codeql.yml) — separate security scan on push/PR/schedule tomaster - Secret: set
BITWARDEN_ACCESS_TOKENviagh secret set BITWARDEN_ACCESS_TOKEN(Varlock job onmasteronly) - Pinned Actions: third-party
uses:pin full commit SHAs (version comment beside) to reduce supply-chain tag mutability; bump via Dependabotgithub-actionsor periodic SHA refresh
This project uses Ultracite for enhanced code quality and AI-friendly development:
- Zero configuration required - Works out of the box with sensible defaults
- Subsecond performance - Lightning-fast linting and formatting
- Maximum type safety - Strict TypeScript rules and accessibility standards
- AI-friendly code generation - Optimized for modern AI development workflows
- Accessibility enforcement - Built-in a11y rules and best practices
- React/Next.js specific rules - Tailored for modern React development
- Ultracite integration via
extends: ["ultracite/biome/react", "ultracite/biome/core", "ultracite/biome/remix"]inbiome.jsonc - CSS at-rules support for TailwindCSS 4 features
- Sorted CSS classes for consistency
- TypeScript strict mode enabled
- Import organization and sorting
- Custom rules for class sorting and organization
The application deploys to Cloudflare Workers with static client assets:
- Worker entry -
workers/app.tswith React Router SSR request handler - Wrangler config -
wrangler.jsonc(assets from./build/client,nodejs_compat, D1 bindingDB,cache.enabled) - Workers Cache - edge caching for public GETs; host/auth use
private, no-storeviaapp/constants/cache-headers.ts - Varlock deploy -
bun run deploy:projectrunsvarlock-wrangler deployfor typed secrets - Cloudflare D1 - SQLite via
env.DB; DrizzlecreateDb(d1)in middleware/auth.server.ts - Cloudflare context -
cloudflareContext+dbContextmiddleware shareenv/AppDbwith routes
# Migrate remote D1, then build
bun run db:migrate:remote
bun run build
# Safe path: upload → test preview URL → promote
bun run deploy:upload # preview only
bun run deploy:versions # promote when ready
# Or all-in-one (goes live immediately)
bun run deploy:projectSet production secrets (BETTER_AUTH_SECRET, SITE_URL, etc.) via Varlock/Bitwarden or Wrangler secrets before deploying.
# Production build
bun run build
# Bundle analysis (client/server stats.html + DevTools capture)
bun run analyze
bun run devtools
# Type checking
bun run typecheck
# Linting and formatting
bun run check- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Follow the coding style guide (see
biome.json) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Use Biome with Ultracite for formatting and linting
- Follow TypeScript best practices with Ultracite's strict rules
- Write meaningful commit messages
- Add tests for new features
- Use nuqs for URL state management
- Follow the established project structure
- Follow Ultracite's accessibility and code quality standards
- Pre-commit hooks automatically ensure code quality before commits
This project is for educational/portfolio purposes and demonstrates modern full-stack web development best practices.
Built with ❤️ using React Router 8, TypeScript, nuqs, and modern web technologies.