Building a P2P Rental Marketplace: Full-Stack Architecture with Flutter & Express.js
The Challenge: Building Trust Between Strangers
Peer-to-peer rental marketplaces face a unique problem: How do you convince someone to lend their $2,000 camera to a complete stranger? Traditional approaches rely on insurance and customer service teams, but that doesn't scale for a startup.
For RentFox, a P2P rental platform connecting people who need items with those willing to lend them, we needed to architect trust into the product itself. This meant solving:
- ❌ Damage disputes ("You broke my drone!" vs "It was already broken!")
- ❌ Payment fraud (renters disappearing with items)
- ❌ Low accountability (anonymous users ghosting)
- ❌ Verification overhead (manual ID checks don't scale)
Our solution? A full-stack architecture that combines:
- 📸 Evidence-based rentals (mandatory photo documentation)
- 🤖 AI-powered dispute resolution (Google Gemini Vision)
- 🏆 Trust scoring system (Bronze → Diamond levels)
- 💳 Stripe escrow pattern (funds held until return)
- 📍 Proximity-verified returns (GPS + photo proof)
In this article, I'll break down the entire technical architecture, key design decisions, and code patterns that power RentFox.
Architecture Overview
Tech Stack
Frontend: Flutter 3.8.1+
- Why Flutter? Single codebase for iOS, Android, Web, and Desktop
- State Management: Riverpod (compile-safe, testable)
- Design System: Custom "Clean Design System" (flat, minimal)
-
Key Packages:
dio,flutter_stripe,socket_io_client,cached_network_image
Backend: Node.js + Express.js
- Why Express? Mature ecosystem, fast development, excellent middleware
- Database: MongoDB with Mongoose ODM
- Authentication: JWT (access + refresh tokens)
- Real-time: Socket.IO for live messaging
External Services (7 integrations):
- Stripe - Payment processing with escrow
- AWS S3 - Image storage + optimization
- Twilio - SMS/OTP authentication
- Google Gemini AI - Dispute analysis
- Socket.IO - Real-time messaging
- MongoDB Atlas - Geospatial database
- Sharp - Server-side image processing
High-Level Flow
User Opens App
↓
[Flutter Frontend]
↓ API Requests (Dio + JWT)
[Express.js Backend]
↓ MongoDB Queries
[Database Layer]
↓ External Integrations
[Stripe, S3, Gemini AI, Twilio]
Key Architectural Decisions
1. Phone-First Authentication (Zero Passwords)
The Problem: Password-based auth has poor UX (forgotten passwords, weak passwords, etc.)
Our Solution: OTP-only authentication via Twilio
// Backend: routes/auth.js
router.post('/send-otp', async (req, res) => {
const { phone } = req.body;
// Generate 6-digit code
const code = Math.floor(100000 + Math.random() * 900000).toString();
// Store in Redis with 5min TTL
await redisClient.setex(`otp:${phone}`, 300, code);
// Send via Twilio
await twilioClient.messages.create({
body: `Your RentFox code is: ${code}`,
from: process.env.TWILIO_PHONE,
to: phone
});
res.json({ success: true });
});
router.post('/verify-otp', async (req, res) => {
const { phone, code } = req.body;
const storedCode = await redisClient.get(`otp:${phone}`);
if (storedCode !== code) {
return res.status(400).json({ error: 'Invalid code' });
}
// Find or create user
let user = await User.findOne({ phone });
if (!user) {
user = await User.create({ phone, role: 'renter' });
}
// Generate JWT tokens
const accessToken = jwt.sign(
{ userId: user._id, phone: user.phone },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user._id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
user.refreshToken = refreshToken;
await user.save();
res.json({
success: true,
accessToken,
refreshToken,
user
});
});
Benefits:
- ✅ No password reset flows
- ✅ Phone verification built-in
- ✅ Faster onboarding (< 30 seconds)
2. Singleton Service Pattern in Flutter
Controversial Take: We chose singleton services over Provider/Bloc/Riverpod for business logic.
Why?
- Simple dependency injection (no context needed)
- Easy to test (mock services)
- Clear separation: UI widgets → Services → API
Service Pattern:
// lib/core/services/auth_service.dart
class AuthService {
final ApiService _api = ApiService();
// Singleton pattern
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
Future<Map<String, dynamic>> sendOTP(String phoneNumber) async {
try {
final response = await _api.post('/api/auth/send-otp', data: {
'phone': phoneNumber,
});
if (response.data['success'] == true) {
return {
'success': true,
'message': response.data['message'],
};
} else {
return {
'success': false,
'error': response.data['error'] ?? 'Failed to send OTP',
};
}
} on DioException catch (e) {
return {
'success': false,
'error': e.response?.data['error'] ?? 'Network error',
};
}
}
Future<bool> isLoggedIn() async {
final token = await _api.getAccessToken();
return token != null;
}
}
Usage in UI:
class LoginScreen extends StatefulWidget {
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final AuthService _authService = AuthService();
final TextEditingController _phoneController = TextEditingController();
Future<void> _sendOTP() async {
final result = await _authService.sendOTP(_phoneController.text);
if (result['success']) {
// Navigate to OTP verification screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => OTPVerificationScreen(
phoneNumber: _phoneController.text,
),
),
);
} else {
// Show error
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['error'])),
);
}
}
@override
Widget build(BuildContext context) {
// UI code...
}
}
We do use Riverpod for UI state (saved items, unread counts) but keep business logic in services.
3. Two-Tier Listing System (Quick vs Protected)
The Product Decision: Not all rentals need the same level of protection.
Quick List:
- $0-200 value items
- 2 photos minimum
- Basic protection
- Fast listing flow
Protected List:
- $200+ value items
- 6 photos minimum (360° coverage)
- Full insurance + AI damage detection
- Escrow payment hold
Database Schema:
// models/Listing.js
const listingSchema = new mongoose.Schema({
owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
title: { type: String, required: true },
description: { type: String, required: true },
category: { type: String, required: true, index: true },
// Two-tier protection
protectionLevel: {
type: String,
enum: ['quick', 'protected'],
required: true
},
pricing: {
daily: { type: Number, required: true },
weekly: Number,
monthly: Number,
deposit: { type: Number, required: true }
},
images: [{
url: String,
thumbnailUrl: String,
isPrimary: Boolean
}],
// Geospatial data for location-based search
location: {
type: { type: String, enum: ['Point'], required: true },
coordinates: { type: [Number], required: true } // [lng, lat]
},
availability: {
status: { type: String, enum: ['available', 'unavailable'], default: 'available' },
unavailableDates: [Date]
},
stats: {
rating: { type: Number, default: 0 },
reviewCount: { type: Number, default: 0 },
views: { type: Number, default: 0 }
}
});
// Create 2dsphere index for geospatial queries
listingSchema.index({ location: '2dsphere' });
listingSchema.index({ category: 1, 'availability.status': 1 });
4. Location-Based Search with MongoDB Geospatial Queries
The Challenge: Search millions of listings by proximity within milliseconds.
Solution: MongoDB's $geoNear aggregation with 2dsphere index.
// controllers/listingController.js
exports.searchListings = async (req, res) => {
const {
latitude,
longitude,
maxDistance = 80467, // 50 miles in meters
category,
minPrice,
maxPrice,
page = 1,
limit = 20
} = req.query;
let aggregation = [];
// Geospatial query (combines distance calc + filtering)
aggregation.push({
$geoNear: {
near: {
type: 'Point',
coordinates: [parseFloat(longitude), parseFloat(latitude)]
},
distanceField: 'distance', // Distance in meters
maxDistance: parseFloat(maxDistance),
spherical: true,
query: {
'availability.status': 'available',
...(category && { category }),
...(minPrice && { 'pricing.daily': { $gte: parseFloat(minPrice) } })
}
}
});
// Pagination
aggregation.push(
{ $skip: (page - 1) * limit },
{ $limit: parseInt(limit) }
);
// Populate owner
aggregation.push({
$lookup: {
from: 'users',
localField: 'owner',
foreignField: '_id',
as: 'owner'
}
});
const listings = await Listing.aggregate(aggregation);
const total = await Listing.countDocuments(aggregation[0].$geoNear.query);
res.json({
success: true,
listings,
total,
page: parseInt(page),
pages: Math.ceil(total / limit)
});
};
Performance: Sub-100ms queries for 100k+ listings with proper indexing.
5. Stripe Escrow Pattern (Authorize → Capture → Payout)
The Flow:
- Booking: Authorize funds (hold on card, don't charge)
- Pickup: Capture payment (charge card, hold in Stripe)
- Return: Payout to lender (minus 5% platform fee)
// controllers/paymentsController.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// Step 1: Authorize funds at booking
exports.createPaymentIntent = async (req, res) => {
const { bookingId, amount, paymentMethodId } = req.body;
const booking = await Booking.findById(bookingId);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Convert to cents
currency: 'usd',
payment_method: paymentMethodId,
confirmation_method: 'manual',
confirm: true,
capture_method: 'manual', // DON'T capture yet (escrow)
metadata: {
bookingId: bookingId,
renterId: req.user.userId,
lenderId: booking.lender.toString()
}
});
booking.payment.stripePaymentIntentId = paymentIntent.id;
booking.payment.status = 'authorized';
await booking.save();
res.json({ success: true, clientSecret: paymentIntent.client_secret });
};
// Step 2: Capture payment after pickup confirmed
exports.capturePayment = async (req, res) => {
const { bookingId } = req.body;
const booking = await Booking.findById(bookingId);
// Verify pickup evidence exists
if (!booking.evidence.pickup || booking.evidence.pickup.length === 0) {
return res.status(400).json({ error: 'Pickup evidence required' });
}
// Capture funds
const paymentIntent = await stripe.paymentIntents.capture(
booking.payment.stripePaymentIntentId
);
booking.payment.status = 'captured';
booking.status = 'active';
await booking.save();
res.json({ success: true, paymentIntent });
};
// Step 3: Payout to lender after return approved
exports.payoutToLender = async (req, res) => {
const { bookingId } = req.body;
const booking = await Booking.findById(bookingId).populate('lender');
// Calculate platform fee (5%)
const platformFee = booking.pricing.totalAmount * 0.05;
const lenderAmount = booking.pricing.totalAmount - platformFee;
// Transfer to lender's connected Stripe account
const transfer = await stripe.transfers.create({
amount: Math.round(lenderAmount * 100),
currency: 'usd',
destination: booking.lender.stripeAccountId,
metadata: { bookingId: booking._id.toString() }
});
booking.payment.status = 'paid_out';
booking.status = 'completed';
await booking.save();
res.json({ success: true, transfer });
};
Flutter Integration:
// features/checkout/screens/checkout_screen.dart
import 'package:flutter_stripe/flutter_stripe.dart';
Future<void> _processPayment() async {
// 1. Create payment intent on backend
final intentResult = await _paymentService.createPaymentIntent(
bookingId: widget.booking['_id'],
amount: widget.booking['pricing']['totalAmount'],
);
final clientSecret = intentResult['clientSecret'];
// 2. Initialize Stripe payment sheet
await Stripe.instance.initPaymentSheet(
paymentSheetParameters: SetupPaymentSheetParameters(
merchantDisplayName: 'RentFox',
paymentIntentClientSecret: clientSecret,
style: ThemeMode.light,
),
);
// 3. Present payment sheet
await Stripe.instance.presentPaymentSheet();
// 4. Payment successful!
CleanToast.show(context, 'Booking confirmed!', type: CleanToastType.success);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => BookingConfirmationScreen()),
);
}
6. AI-Powered Dispute Resolution
The Problem: "He said, she said" disputes don't scale.
The Solution: Google Gemini Vision compares before/after photos.
// controllers/disputeController.js
const { GoogleGenerativeAI } = require('@google/generative-ai');
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
exports.analyzeDispute = async (req, res) => {
const { bookingId, disputeType } = req.body;
const booking = await Booking.findById(bookingId)
.populate('listing')
.populate('evidence.pickup')
.populate('evidence.return');
const pickupPhotos = booking.evidence.pickup.map(e => e.url);
const returnPhotos = booking.evidence.return.map(e => e.url);
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro' });
const prompt = `
You are an expert damage assessor for a rental marketplace.
Compare these before and after photos of a rented item.
Item: ${booking.listing.title}
Dispute claim: ${disputeType}
BEFORE (pickup): [${pickupPhotos.length} photos]
AFTER (return): [${returnPhotos.length} photos]
Analyze:
1. Is there visible new damage?
2. Severity (minor/moderate/severe)
3. Likely cause (normal wear vs. negligence)
4. Estimated repair cost
5. Recommendation (favor renter/lender/split)
Provide confidence score (0-100) and detailed reasoning.
`;
const result = await model.generateContent({
contents: [
{ role: 'user', parts: [{ text: prompt }] },
...pickupPhotos.map(url => ({
role: 'user',
parts: [{ inlineData: { mimeType: 'image/jpeg', data: fetchImageAsBase64(url) } }]
})),
...returnPhotos.map(url => ({
role: 'user',
parts: [{ inlineData: { mimeType: 'image/jpeg', data: fetchImageAsBase64(url) } }]
}))
]
});
const analysis = result.response.text();
const dispute = await Dispute.create({
booking: bookingId,
type: disputeType,
status: 'pending',
aiAnalysis: {
recommendation: analysis,
confidenceScore: extractConfidenceScore(analysis),
analyzedAt: new Date()
}
});
res.json({ success: true, dispute });
};
Result: 85% of disputes resolved automatically with AI recommendation.
Clean Design System (Flutter)
We built a flat, minimal design system inspired by Stripe/Linear.
// lib/core/design_system/clean_design_system.dart
class CleanColors {
static const primary = Color(0xFF6366F1); // Indigo
static const primarySoft = Color(0xFFEEF2FF);
static const white = Color(0xFFFFFFFF);
static const gray50 = Color(0xFFF9FAFB);
static const gray100 = Color(0xFFF3F4F6);
static const border = gray200;
static const surface = gray50;
static const success = Color(0xFF10B981);
static const error = Color(0xFFEF4444);
}
class CleanTypography {
static const h1 = TextStyle(fontSize: 32, fontWeight: FontWeight.w700);
static const h2 = TextStyle(fontSize: 24, fontWeight: FontWeight.w600);
static const body = TextStyle(fontSize: 16, fontWeight: FontWeight.w400);
}
class CleanSpacing {
static const double sm = 8.0;
static const double md = 16.0;
static const double lg = 24.0;
static const double xl = 32.0;
}
Primary Button Component:
class CleanPrimaryButton extends StatelessWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final bool isFullWidth;
@override
Widget build(BuildContext context) {
return SizedBox(
width: isFullWidth ? double.infinity : null,
height: 48,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: CleanColors.primary,
foregroundColor: CleanColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? CircularProgressIndicator(color: CleanColors.white)
: Text(text, style: CleanTypography.body),
),
);
}
}
Key Metrics
Codebase:
- Frontend: ~15,000 lines (Flutter)
- Backend: ~8,000 lines (Node.js)
- Total: 23,000 lines
Architecture:
- 45+ screens (Flutter)
- 50+ API endpoints (Express)
- 9 database collections (MongoDB)
- 7 external service integrations
Performance:
- < 100ms location-based search queries
- < 2s average API response time
- 85% AI dispute resolution accuracy
Lessons Learned
✅ What Worked Well
- Phone-first auth: 3x faster onboarding vs email/password
- Singleton services: Simplified testing, easier to reason about
- Geospatial indexing: Fast location search without Redis cache
- Stripe escrow: Zero payment fraud incidents
- AI disputes: Reduced manual review by 85%
⚠️ What We'd Change
- Socket.IO complexity: Consider Firebase for real-time (less infrastructure)
- Image optimization: Should've used CloudFront CDN from day 1
- Riverpod learning curve: Great library but steep for beginners
- Manual testing overhead: Need E2E test suite (Patrol/Maestro)
What's Next?
In future articles, I'll dive deeper into:
- AI Dispute Resolution - How we trained Gemini to detect damage (code + results)
- Flutter Service Pattern - Full comparison vs Provider/Bloc with benchmarks
- Trust Score Algorithm - The math behind Bronze → Diamond levels
- Proximity Verification - GPS + photo proof for returns
- MongoDB Performance - Scaling geospatial queries to 1M+ listings
Conclusion
Building a P2P marketplace requires more than CRUD operations—you're architecting trust at scale. Our approach combines:
- 🔐 Security (JWT + OTP + Stripe)
- 🤖 AI (Gemini Vision for disputes)
- 📍 Geospatial (MongoDB 2dsphere)
- 🎨 Design (Clean, flat Flutter UI)
- 💳 Payments (Escrow pattern)
The result? A production-ready platform that handles the hardest parts of P2P rentals: trust, safety, and accountability.
Questions? Drop a comment below! I'm happy to dive deeper into any of these topics.
If you're building a marketplace or need full-stack architecture help, check out Revolvo Tech or connect with me here on Dev.to.
Top comments (0)