Back to home

Building Production APIs with Next.js: A Real-World Case Study

Wahab Cide

Wahab Cide

How we built a production-grade rideshare API serving thousands of users using Next.js App Router, PostgreSQL, and serverless architecture—no complex backend setup required.

Introduction

When building Loop, a rideshare platform connecting university students, we needed a robust backend capable of handling complex operations: real-time bookings, payment processing, location tracking, and chat functionality. The traditional approach would involve setting up Express.js or FastAPI, configuring database connections, managing CORS, implementing authentication middleware, and orchestrating deployment infrastructure.

Instead, we took a different path: npx create-next-app@latest and started writing API routes.

This article explores how Next.js App Router's API Routes enabled us to build a production-grade backend entirely in TypeScript, deployed as serverless functions on Vercel, with zero DevOps overhead. We'll examine real implementation patterns from our codebase, covering authentication, database transactions, payment processing, and webhook handling.

Tech Stack

  • Framework: Next.js 14 (App Router)
  • Database: PostgreSQL (Neon serverless)
  • Authentication: Clerk
  • Payments: Stripe & Stripe Connect
  • Deployment: Vercel (serverless functions)
  • Language: TypeScript

Architecture Overview

Next.js App Router introduces a file-system based routing convention where each route.ts file becomes a serverless API endpoint. This eliminates the boilerplate of traditional backend frameworks while maintaining type safety and developer experience.

Directory Structure
app/api/
├── rides/
│   ├── feed/route.ts          # GET /api/rides/feed
│   ├── search/route.ts        # POST /api/rides/search
│   ├── create/route.ts        # POST /api/rides/create
│   └── [rideId]/
│       ├── route.ts           # GET /api/rides/:rideId
│       ├── cancel/route.ts    # POST /api/rides/:rideId/cancel
│       └── complete/route.ts  # POST /api/rides/:rideId/complete
├── bookings/
│   ├── create/route.ts
│   └── [bookingId]/
│       ├── approve/route.ts
│       └── cancel/route.ts
├── stripe/
│   ├── create/route.ts        # Create payment intent
│   └── pay/route.ts           # Process payment
├── webhooks/
│   ├── clerk/route.ts         # Clerk webhooks
│   └── stripe/route.ts        # Stripe webhooks
└── user/route.ts              # User profile management

Each route file exports HTTP method handlers (GET, POST, PUT, DELETE) as async functions. Dynamic segments like [rideId] become available as parameters, enabling RESTful patterns without manual routing configuration.

API Routes Structure

Let's examine a production endpoint that retrieves available rides. This demonstrates the fundamental pattern used across our API: authentication, input validation, database queries, and consistent response formatting.

app/api/rides/feed/route.ts
import { auth } from '@clerk/nextjs/server';
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL!);

export async function GET(request: Request) {
  try {
    // 1. Authentication
    const { userId } = await auth();
    if (!userId) {
      return Response.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    // 2. Query parameters
    const { searchParams } = new URL(request.url);
    const limit = parseInt(searchParams.get('limit') || '20');
    const offset = parseInt(searchParams.get('offset') || '0');

    // 3. Database query
    const rides = await sql`
      SELECT
        r.id,
        r.origin_label,
        r.origin_lat,
        r.origin_lng,
        r.destination_label,
        r.destination_lat,
        r.destination_lng,
        r.departure_time,
        r.arrival_time,
        r.seats_available,
        r.seats_total,
        r.price,
        r.currency,
        r.status,
        r.fare_splitting_enabled,
        u.clerk_id as driver_id,
        u.name as driver_name,
        u.avatar_url as driver_avatar,
        u.rating_driver,
        u.vehicle_make,
        u.vehicle_model,
        u.vehicle_year,
        u.vehicle_color,
        u.vehicle_plate
      FROM rides r
      JOIN users u ON r.driver_id = u.id
      WHERE r.status = 'open'
        AND r.seats_available > 0
        AND r.departure_time > NOW()
      ORDER BY r.departure_time ASC
      LIMIT ${limit}
      OFFSET ${offset}
    `;

    // 4. Response formatting
    return Response.json({
      success: true,
      rides: rides.map(ride => ({
        id: ride.id,
        driver_id: ride.driver_id,
        origin: {
          label: ride.origin_label,
          latitude: parseFloat(ride.origin_lat),
          longitude: parseFloat(ride.origin_lng),
        },
        destination: {
          label: ride.destination_label,
          latitude: parseFloat(ride.destination_lat),
          longitude: parseFloat(ride.destination_lng),
        },
        departure_time: ride.departure_time,
        arrival_time: ride.arrival_time,
        seats_available: ride.seats_available,
        seats_total: ride.seats_total,
        price: parseFloat(ride.price),
        currency: ride.currency,
        status: ride.status,
        fare_splitting_enabled: ride.fare_splitting_enabled,
        driver: {
          name: ride.driver_name,
          avatar_url: ride.driver_avatar,
          rating: parseFloat(ride.rating_driver),
          vehicle: {
            make: ride.vehicle_make,
            model: ride.vehicle_model,
            year: ride.vehicle_year,
            color: ride.vehicle_color,
            plate: ride.vehicle_plate,
          },
        },
      })),
      total: rides.length,
    });

  } catch (error) {
    console.error('Error fetching rides:', error);
    return Response.json(
      { error: 'Failed to fetch rides' },
      { status: 500 }
    );
  }
}

This pattern establishes several conventions:

  • Authentication First: Every protected route validates the user's Clerk session before processing.
  • Input Validation: Query parameters are parsed and validated with defaults.
  • Parameterized Queries: Template literals with the sql tag prevent SQL injection.
  • Consistent Responses: All endpoints return { success: true, data } on success or { error: string } on failure.
  • Type Transformations: Database numerics are explicitly parsed to JavaScript numbers.

Database Integration

For production workloads, we use Neon's serverless PostgreSQL driver (@neondatabase/serverless), designed specifically for serverless environments like Vercel Edge Functions. Unlike traditional PostgreSQL clients that maintain persistent connections, Neon's driver operates over HTTP, eliminating connection pooling concerns inherent to serverless architectures.

lib/database.ts

import { neon } from '@neondatabase/serverless';

// Initialize once, reuse across requests
export const sql = neon(process.env.DATABASE_URL!);

// Example: Complex join with aggregation
export async function getRideWithBookings(rideId: string) {
  const result = await sql`
    SELECT
      r.*,
      json_agg(
        json_build_object(
          'id', b.id,
          'rider_id', b.rider_id,
          'seats_requested', b.seats_requested,
          'status', b.status,
          'rider_name', u.name,
          'rider_avatar', u.avatar_url
        )
      ) FILTER (WHERE b.id IS NOT NULL) as bookings
    FROM rides r
    LEFT JOIN bookings b ON b.ride_id = r.id
    LEFT JOIN users u ON b.rider_id = u.id
    WHERE r.id = ${rideId}
    GROUP BY r.id
  `;

  return result[0];
}

Schema Design

Our database schema leverages PostgreSQL's advanced features: JSONB for flexible data, PostGIS for geospatial queries, and triggers for automatic timestamp management.

database/schema.sql (excerpt)

CREATE TYPE ride_status AS ENUM ('open', 'full', 'completed', 'cancelled', 'expired');

CREATE TABLE rides (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  driver_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,

  -- Geographic data
  origin_label TEXT NOT NULL,
  origin_lat NUMERIC(10,6) NOT NULL,
  origin_lng NUMERIC(10,6) NOT NULL,
  destination_label TEXT NOT NULL,
  destination_lat NUMERIC(10,6) NOT NULL,
  destination_lng NUMERIC(10,6) NOT NULL,

  -- Temporal data
  departure_time TIMESTAMPTZ NOT NULL,
  arrival_time TIMESTAMPTZ,

  -- Capacity management
  seats_total SMALLINT NOT NULL CHECK (seats_total > 0),
  seats_available SMALLINT NOT NULL
    CHECK (seats_available >= 0 AND seats_available <= seats_total),

  -- Pricing
  price NUMERIC(8,2) NOT NULL,
  currency CHAR(3) NOT NULL DEFAULT 'USD',
  fare_splitting_enabled BOOLEAN DEFAULT false,

  -- State
  status ride_status NOT NULL DEFAULT 'open',

  -- Audit
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Automatic timestamp updates
CREATE TRIGGER update_rides_updated_at
  BEFORE UPDATE ON rides
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

-- Prevent overbooking
CREATE TRIGGER prevent_negative_seats
  BEFORE UPDATE ON rides
  FOR EACH ROW
  WHEN (NEW.seats_available < 0)
  EXECUTE FUNCTION raise_seats_exception();

Authentication Patterns

We use Clerk for authentication, which provides JWT-based session management and integrates seamlessly with Next.js. The mobile app (React Native) sends Clerk tokens in the Authorization header, which the API validates server-side.

Authentication Flow

// Client (React Native)
import { useUser } from '@clerk/clerk-expo';

const { user } = useUser();
const token = await user.getToken();

const response = await fetch('/api/rides/create', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(rideData),
});

// Server (Next.js API Route)
import { auth } from '@clerk/nextjs/server';

export async function POST(request: Request) {
  const { userId } = await auth();

  if (!userId) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // userId is the Clerk ID - use it to query database
  const user = await sql`
    SELECT * FROM users WHERE clerk_id = ${userId}
  `;

  // ... rest of handler
}

User Synchronization

Clerk manages authentication, but our PostgreSQL database stores application-specific user data (ratings, vehicle info, etc.). We synchronize users via Clerk webhooks:

app/api/webhooks/clerk/route.ts

import { Webhook } from 'svix';
import { WebhookEvent } from '@clerk/nextjs/server';

export async function POST(request: Request) {
  const payload = await request.text();
  const headers = Object.fromEntries(request.headers);

  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);

  let event: WebhookEvent;
  try {
    event = wh.verify(payload, headers) as WebhookEvent;
  } catch (error) {
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'user.created') {
    const { id, email_addresses, first_name, last_name, image_url } = event.data;

    await sql`
      INSERT INTO users (clerk_id, email, name, avatar_url)
      VALUES (
        ${id},
        ${email_addresses[0].email_address},
        ${first_name + ' ' + last_name},
        ${image_url}
      )
      ON CONFLICT (clerk_id) DO NOTHING
    `;
  }

  if (event.type === 'user.updated') {
    const { id, email_addresses, first_name, last_name, image_url } = event.data;

    await sql`
      UPDATE users
      SET
        email = ${email_addresses[0].email_address},
        name = ${first_name + ' ' + last_name},
        avatar_url = ${image_url},
        updated_at = NOW()
      WHERE clerk_id = ${id}
    `;
  }

  return Response.json({ received: true });
}

Atomic Operations & Transactions

The most critical operation in our system is ride booking. This involves multiple database writes that must succeed or fail together:

  • Verify ride has available seats
  • Decrement seats_available on the ride
  • Create booking record
  • Create Stripe payment intent
  • Update ride status if fully booked

Without proper transaction handling, race conditions could allow overbooking. Here's how we handle this:

app/api/bookings/create/route.ts

import { auth } from '@clerk/nextjs/server';
import { neon } from '@neondatabase/serverless';
import Stripe from 'stripe';

const sql = neon(process.env.DATABASE_URL!);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  try {
    const { userId } = await auth();
    if (!userId) {
      return Response.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const { rideId, seatsRequested } = await request.json();

    // Validation
    if (!rideId || !seatsRequested || seatsRequested < 1) {
      return Response.json(
        { error: 'Invalid booking data' },
        { status: 400 }
      );
    }

    // Get user's database ID
    const [user] = await sql`
      SELECT id FROM users WHERE clerk_id = ${userId}
    `;

    if (!user) {
      return Response.json({ error: 'User not found' }, { status: 404 });
    }

    // BEGIN TRANSACTION
    // We use a transaction to ensure atomicity
    const result = await sql.transaction([
      // 1. Check and decrement seats (with row lock)
      sql`
        UPDATE rides
        SET
          seats_available = seats_available - ${seatsRequested},
          status = CASE
            WHEN seats_available - ${seatsRequested} = 0 THEN 'full'::ride_status
            ELSE status
          END,
          updated_at = NOW()
        WHERE id = ${rideId}
          AND seats_available >= ${seatsRequested}
          AND status = 'open'
        RETURNING *
      `,

      // 2. Create booking record
      sql`
        INSERT INTO bookings (
          ride_id,
          rider_id,
          seats_requested,
          status,
          total_price
        )
        SELECT
          ${rideId},
          ${user.id},
          ${seatsRequested},
          'pending',
          price * ${seatsRequested}
        FROM rides
        WHERE id = ${rideId}
        RETURNING *
      `
    ]);

    const [updatedRide, booking] = result;

    // If UPDATE affected 0 rows, ride is unavailable
    if (!updatedRide || updatedRide.length === 0) {
      return Response.json(
        { error: 'Ride is no longer available or has insufficient seats' },
        { status: 409 }
      );
    }

    // 3. Create Stripe payment intent
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Math.round(booking[0].total_price * 100), // Convert to cents
      currency: updatedRide[0].currency.toLowerCase(),
      metadata: {
        bookingId: booking[0].id,
        rideId: rideId,
        userId: userId,
      },
      // Don't capture immediately - capture on ride completion
      capture_method: 'manual',
    });

    // 4. Update booking with payment intent ID
    await sql`
      UPDATE bookings
      SET payment_intent_id = ${paymentIntent.id}
      WHERE id = ${booking[0].id}
    `;

    return Response.json({
      success: true,
      booking: {
        id: booking[0].id,
        rideId: booking[0].ride_id,
        seatsRequested: booking[0].seats_requested,
        totalPrice: parseFloat(booking[0].total_price),
        status: booking[0].status,
        clientSecret: paymentIntent.client_secret,
      },
    });

  } catch (error) {
    console.error('Booking creation error:', error);
    return Response.json(
      { error: 'Failed to create booking' },
      { status: 500 }
    );
  }
}

Why This Works

  • Row-Level Locking: The UPDATE ... WHERE clause with seat check creates an implicit row lock, preventing concurrent bookings.
  • Atomic Updates: Both the seat decrement and booking creation happen in a single transaction.
  • Optimistic Concurrency: If the UPDATE returns 0 rows (seats already taken), we return a 409 Conflict.
  • Payment Intent Separation: Stripe operations are outside the transaction—if they fail, we can handle cleanup separately.

Payment Processing with Stripe

Loop operates as a multi-sided marketplace: riders pay for rides, and drivers receive payouts. This requires Stripe Connect to handle platform fees, driver onboarding, and compliant money movement.

Driver Onboarding

Drivers must create a Stripe Express account to receive payouts. We handle this via account links:

app/api/payout/account/create/route.ts

import { auth } from '@clerk/nextjs/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const { userId } = await auth();
  if (!userId) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { email } = await request.json();

  try {
    // Create Stripe Connect Express account
    const account = await stripe.accounts.create({
      type: 'express',
      country: 'US',
      email: email,
      capabilities: {
        transfers: { requested: true },
      },
      business_type: 'individual',
    });

    // Store account ID in database
    await sql`
      INSERT INTO driver_payout_accounts (
        user_id,
        stripe_account_id,
        status
      )
      SELECT id, ${account.id}, 'pending'
      FROM users
      WHERE clerk_id = ${userId}
    `;

    // Generate account link for onboarding
    const accountLink = await stripe.accountLinks.create({
      account: account.id,
      refresh_url: `${process.env.APP_URL}/payout/refresh`,
      return_url: `${process.env.APP_URL}/payout/complete`,
      type: 'account_onboarding',
    });

    return Response.json({
      success: true,
      accountId: account.id,
      onboardingUrl: accountLink.url,
    });

  } catch (error) {
    console.error('Stripe account creation error:', error);
    return Response.json(
      { error: 'Failed to create payout account' },
      { status: 500 }
    );
  }
}

Payment Flow

When a ride is completed, we transfer funds to the driver's Connect account and capture the platform fee:

app/api/rides/[rideId]/complete/route.ts

export async function POST(
  request: Request,
  { params }: { params: { rideId: string } }
) {
  const { userId } = await auth();
  if (!userId) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { rideId } = params;

  try {
    // Verify this is the driver's ride
    const [ride] = await sql`
      SELECT r.*, u.clerk_id
      FROM rides r
      JOIN users u ON r.driver_id = u.id
      WHERE r.id = ${rideId}
    `;

    if (!ride || ride.clerk_id !== userId) {
      return Response.json({ error: 'Unauthorized' }, { status: 403 });
    }

    // Get all confirmed bookings for this ride
    const bookings = await sql`
      SELECT * FROM bookings
      WHERE ride_id = ${rideId}
        AND status = 'confirmed'
        AND payment_intent_id IS NOT NULL
    `;

    // Get driver's Stripe Connect account
    const [driverAccount] = await sql`
      SELECT stripe_account_id
      FROM driver_payout_accounts dpa
      JOIN users u ON dpa.user_id = u.id
      WHERE u.clerk_id = ${userId}
        AND dpa.status = 'active'
    `;

    if (!driverAccount) {
      return Response.json(
        { error: 'Driver payout account not set up' },
        { status: 400 }
      );
    }

    // Process each booking payment
    const PLATFORM_FEE_PERCENT = 0.15; // 15% platform fee

    for (const booking of bookings) {
      // Capture the payment intent
      const paymentIntent = await stripe.paymentIntents.capture(
        booking.payment_intent_id
      );

      const totalAmount = paymentIntent.amount;
      const platformFee = Math.round(totalAmount * PLATFORM_FEE_PERCENT);
      const driverPayout = totalAmount - platformFee;

      // Transfer to driver's Connect account
      const transfer = await stripe.transfers.create({
        amount: driverPayout,
        currency: paymentIntent.currency,
        destination: driverAccount.stripe_account_id,
        transfer_group: rideId,
        metadata: {
          bookingId: booking.id,
          rideId: rideId,
        },
      });

      // Record transaction
      await sql`
        INSERT INTO payout_transactions (
          booking_id,
          driver_account_id,
          amount,
          platform_fee,
          stripe_transfer_id,
          status
        )
        VALUES (
          ${booking.id},
          ${driverAccount.stripe_account_id},
          ${driverPayout / 100},
          ${platformFee / 100},
          ${transfer.id},
          'completed'
        )
      `;
    }

    // Mark ride as completed
    await sql`
      UPDATE rides
      SET
        status = 'completed',
        updated_at = NOW()
      WHERE id = ${rideId}
    `;

    return Response.json({
      success: true,
      message: 'Ride completed and payments processed',
      bookingsProcessed: bookings.length,
    });

  } catch (error) {
    console.error('Ride completion error:', error);
    return Response.json(
      { error: 'Failed to complete ride' },
      { status: 500 }
    );
  }
}

Webhook Handlers

Webhooks are critical for handling asynchronous events from external services. Stripe sends webhooks for payment events, and Clerk sends them for user lifecycle events.

Stripe Webhook Security

Stripe signs webhooks using HMAC-SHA256. We verify signatures to prevent replay attacks:

app/api/webhooks/stripe/route.ts

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const payload = await request.text();
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return Response.json({ error: 'No signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      payload,
      signature,
      webhookSecret
    );
  } catch (error) {
    console.error('Webhook signature verification failed:', error);
    return Response.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      const paymentIntent = event.data.object as Stripe.PaymentIntent;

      // Update booking status
      await sql`
        UPDATE bookings
        SET
          status = 'confirmed',
          payment_confirmed_at = NOW()
        WHERE payment_intent_id = ${paymentIntent.id}
      `;

      // Send confirmation notification
      const [booking] = await sql`
        SELECT b.*, u.name as rider_name
        FROM bookings b
        JOIN users u ON b.rider_id = u.id
        WHERE b.payment_intent_id = ${paymentIntent.id}
      `;

      if (booking) {
        await sendPushNotification(
          booking.rider_id,
          'Payment Confirmed',
          'Your booking has been confirmed!'
        );
      }
      break;

    case 'payment_intent.payment_failed':
      const failedIntent = event.data.object as Stripe.PaymentIntent;

      // Return seats to ride
      await sql.transaction([
        sql`
          UPDATE rides r
          SET seats_available = seats_available + b.seats_requested
          FROM bookings b
          WHERE b.ride_id = r.id
            AND b.payment_intent_id = ${failedIntent.id}
        `,
        sql`
          UPDATE bookings
          SET status = 'cancelled'
          WHERE payment_intent_id = ${failedIntent.id}
        `
      ]);
      break;

    case 'account.updated':
      const account = event.data.object as Stripe.Account;

      // Update driver account status
      await sql`
        UPDATE driver_payout_accounts
        SET
          status = ${account.charges_enabled ? 'active' : 'pending'},
          updated_at = NOW()
        WHERE stripe_account_id = ${account.id}
      `;
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return Response.json({ received: true });
}

Key considerations for webhook handlers:

  • Idempotency: Webhooks can be delivered multiple times. Use unique identifiers to prevent duplicate processing.
  • Error Handling: Always return 200 OK even if processing fails internally—log errors for debugging.
  • Async Processing: For long-running tasks, queue them separately and acknowledge the webhook immediately.

Deployment & Production Considerations

Deploying to Vercel requires zero configuration for Next.js projects. Each API route becomes a serverless function with automatic scaling and edge caching.

Deployment Steps

# 1. Install Vercel CLI
npm i -g vercel

# 2. Link project
vercel link

# 3. Set environment variables
vercel env add DATABASE_URL
vercel env add CLERK_SECRET_KEY
vercel env add STRIPE_SECRET_KEY
vercel env add STRIPE_WEBHOOK_SECRET

# 4. Deploy to production
vercel --prod

Performance Optimizations

1. Edge Caching

For read-heavy endpoints like ride feed, we use Vercel's Edge Cache:

export const revalidate = 60; // Cache for 60 seconds

export async function GET(request: Request) {
  // This response is cached at the edge
  const rides = await sql`SELECT * FROM rides WHERE status = 'open'`;
  return Response.json({ rides });
}

2. Database Query Optimization

We use indexes on frequently queried columns:

-- Index on status and departure time for feed queries
CREATE INDEX idx_rides_status_departure
  ON rides(status, departure_time)
  WHERE status = 'open';

-- Composite index for location-based searches
CREATE INDEX idx_rides_destination_coords
  ON rides(destination_lat, destination_lng);

-- Index on foreign keys for joins
CREATE INDEX idx_bookings_ride_id ON bookings(ride_id);
CREATE INDEX idx_bookings_rider_id ON bookings(rider_id);

3. Connection Pooling

Neon's serverless driver handles connections over HTTP, eliminating traditional pooling concerns. For high-traffic endpoints, we use Neon's connection pooling feature:

// Use pooled connection string from Neon dashboard
DATABASE_URL=postgres://user:pass@host/db?sslmode=require&pgbouncer=true

4. Error Monitoring

All errors are logged to Vercel's logging infrastructure, accessible via the dashboard. For production systems, integrate with Sentry or Datadog:

import * as Sentry from '@sentry/nextjs';

export async function POST(request: Request) {
  try {
    // ... handler logic
  } catch (error) {
    Sentry.captureException(error, {
      tags: { endpoint: 'bookings/create' },
      extra: { rideId, userId },
    });

    return Response.json({ error: 'Internal error' }, { status: 500 });
  }
}

Security Best Practices

  • Environment Variables: Never commit secrets—use Vercel environment variables.
  • CORS: Configure allowed origins in next.config.ts for mobile app domains.
  • Rate Limiting: Implement rate limiting for sensitive endpoints using Vercel Edge Config or Upstash.
  • Input Validation: Always validate and sanitize user input before database queries.
  • SQL Injection Prevention: Use parameterized queries exclusively—never string concatenation.

Conclusion

Next.js App Router's API Routes provide a compelling alternative to traditional backend frameworks for production applications. By leveraging serverless architecture, we achieved:

  • Zero Infrastructure Management: No servers to provision, scale, or maintain.
  • Type Safety: End-to-end TypeScript from database queries to HTTP responses.
  • Simplified Deployment: git push triggers automatic deployment with edge caching.
  • Cost Efficiency: Pay only for execution time with Vercel's serverless pricing.
  • Developer Experience: File-based routing, hot reload, and integrated tooling accelerate iteration.

For Loop, this architecture handles thousands of daily requests across ride creation, booking management, payment processing, and real-time chat—all without a dedicated backend team. The patterns demonstrated here scale from MVP to production, making Next.js an excellent choice for full-stack TypeScript applications.

Key Takeaways

  • ✓ Next.js API Routes eliminate backend framework boilerplate
  • ✓ Serverless PostgreSQL (Neon) pairs perfectly with edge functions
  • ✓ Clerk provides robust authentication with minimal integration effort
  • ✓ Stripe Connect enables marketplace payments without PCI compliance burden
  • ✓ Database transactions ensure data consistency in distributed environments
  • ✓ Webhook handlers require signature verification and idempotent processing
  • ✓ TypeScript across the stack reduces runtime errors and improves DX

The complete Loop API codebase demonstrates these patterns in a production environment. For questions or deeper technical discussions, feel free to reach out.