The Project
A café discovery platform needed to launch across three surfaces simultaneously:
- Web dashboard for café owners to manage listings
- Mobile app for customers to discover and review cafés
- Embedded kiosk for in-store loyalty point redemption
Timeline: 4 months. Team: 3 developers.
The Architecture Decision
We evaluated several approaches:
| Approach | Pros | Cons | |----------|------|------| | Separate backends | Surface-specific optimization | Code duplication, sync issues | | Backend-for-frontend (BFF) | Tailored APIs | Three APIs to maintain | | Unified API | Single source of truth | Potential over-fetching |
We chose a unified API with surface-aware responses. One backend, smart enough to serve different clients appropriately.
API Design
Resource-Based with Projections
Instead of separate endpoints per surface, we used field projections:
// Request
GET /api/cafes/123?fields=name,location,hours,menu
// Kiosk only needs:
GET /api/cafes/123?fields=name,loyaltyProgram
// Dashboard needs everything:
GET /api/cafes/123?fields=*
Authentication Contexts
Each surface has different auth requirements:
interface AuthContext {
surface: 'web' | 'mobile' | 'kiosk';
userId?: string;
cafeId?: string; // For kiosk
permissions: Permission[];
}
function authorize(ctx: AuthContext, action: Action): boolean {
// Kiosks can only access their own café's data
if (ctx.surface === 'kiosk') {
return action.cafeId === ctx.cafeId;
}
// Web dashboard requires explicit permissions
if (ctx.surface === 'web') {
return ctx.permissions.includes(action.requiredPermission);
}
// Mobile app has user-scoped access
return action.userId === ctx.userId;
}
Code Organization
We structured the monorepo for parallel development:
/packages
/api # Shared backend
/web # Dashboard (Next.js)
/mobile # App (React Native)
/kiosk # Kiosk (Electron + React)
/shared # Common types, utils, components
Shared Types
TypeScript types were our contract:
// packages/shared/types/cafe.ts
export interface Cafe {
id: string;
name: string;
location: Location;
hours: BusinessHours;
menu: MenuItem[];
loyaltyProgram?: LoyaltyProgram;
ownerDetails?: OwnerDetails; // Web only
}
// Surface-specific views
export type CafeKioskView = Pick<Cafe, 'id' | 'name' | 'loyaltyProgram'>;
export type CafeMobileView = Omit<Cafe, 'ownerDetails'>;
export type CafeDashboardView = Cafe;
Shared Components
UI components that worked across surfaces:
// packages/shared/components/CafeCard.tsx
interface CafeCardProps {
cafe: CafeMobileView | CafeKioskView;
variant: 'compact' | 'detailed' | 'kiosk';
onPress?: () => void;
}
export function CafeCard({ cafe, variant, onPress }: CafeCardProps) {
// Render logic adapts to variant
}
Handling Offline
The kiosk needed offline capability for network outages:
class KioskDataManager {
private cache: LocalCache;
private syncQueue: SyncQueue;
async redeemPoints(customerId: string, points: number) {
const redemption = {
id: generateId(),
customerId,
points,
timestamp: Date.now(),
synced: false
};
// Store locally first
await this.cache.store('redemptions', redemption);
// Queue for sync
this.syncQueue.enqueue({
type: 'REDEEM_POINTS',
payload: redemption
});
// Try immediate sync
this.attemptSync();
return redemption;
}
private async attemptSync() {
if (!navigator.onLine) return;
const pending = await this.syncQueue.getPending();
for (const item of pending) {
try {
await this.api.sync(item);
await this.syncQueue.markComplete(item.id);
} catch (e) {
// Will retry on next attempt
break;
}
}
}
}
Real-Time Updates
The dashboard needed real-time updates when customers checked in:
// API side
io.on('connection', (socket) => {
const { cafeId } = socket.handshake.auth;
socket.join(`cafe:${cafeId}`);
// Broadcast check-ins to dashboard
eventBus.on('customer.checkin', (event) => {
if (event.cafeId === cafeId) {
io.to(`cafe:${cafeId}`).emit('checkin', event);
}
});
});
// Dashboard side
function useLiveCheckins(cafeId: string) {
const [checkins, setCheckins] = useState<Checkin[]>([]);
useEffect(() => {
socket.emit('subscribe', { cafeId });
socket.on('checkin', (checkin) => {
setCheckins(prev => [checkin, ...prev]);
});
return () => socket.off('checkin');
}, [cafeId]);
return checkins;
}
Deployment Strategy
Each surface had different deployment needs:
| Surface | Deployment | Updates | |---------|------------|---------| | Web | Vercel | Instant on push | | Mobile | App stores | Weekly releases | | Kiosk | Self-hosted | OTA with fallback |
The unified API deployed independently, with careful versioning:
// API versioning via headers
app.use((req, res, next) => {
const version = req.headers['x-api-version'] || 'latest';
req.apiVersion = version;
next();
});
Results
We shipped all three surfaces on time:
- Web dashboard: Full feature parity with design specs
- Mobile app: 4.7 star rating on initial release
- Kiosk: 99.9% uptime across 23 locations
The unified backend approach saved approximately 40% development time compared to separate APIs.
Key Takeaways
-
Shared types prevent drift. TypeScript interfaces kept all surfaces in sync.
-
Design for the constraints. Kiosk offline requirements influenced our entire data strategy.
-
Parallel development requires contracts. API contracts were frozen early, allowing independent progress.
-
Different surfaces, different cadences. Mobile app store reviews meant longer cycles—plan accordingly.
Full-stack isn't about one person doing everything. It's about understanding how the pieces connect and designing for coherent, maintainable systems.