Back to Journal
ArchitectureNovember 2023·7 min read

Full-Stack in Practice: Shipping 3 Surfaces from One Backend

Web dashboard, mobile app, and embedded kiosk — all powered by the same API. How we structured the codebase for parallel development.

The Project

A café discovery platform needed to launch across three surfaces simultaneously:

  1. Web dashboard for café owners to manage listings
  2. Mobile app for customers to discover and review cafés
  3. 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

  1. Shared types prevent drift. TypeScript interfaces kept all surfaces in sync.

  2. Design for the constraints. Kiosk offline requirements influenced our entire data strategy.

  3. Parallel development requires contracts. API contracts were frozen early, allowing independent progress.

  4. 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.

TL

TAKKA LABS

Engineering Team

More articles

Have a similar challenge?

We love solving complex technical problems. Let's talk about your project.

Get in touch