DEV Community

Jesee Jackson Kuya
Jesee Jackson Kuya

Posted on

Building Buddy: A Full-Stack Travel App Powered by AWS Aurora PostgreSQL and Vercel

Hackathon submission: This post was written as part of my entry for the H0: Hack the Zero Stack with Vercel v0 and AWS Databases hackathon, sponsored by AWS and Vercel. I used Amazon Aurora PostgreSQL as my database and deployed the entire application on Vercel.


What Is Buddy?

Travel is chaotic. You book a flight, then separately hunt for a hotel, then figure out how you're getting from the airport to your room. Three tabs, three confirmation emails, three things that can go wrong. Buddy is a travel management app that pulls all of it into one place — flights, hotels, and airport transport, coordinated under a single trip.

It's the kind of tool I would have wanted the last time I was scrambling to rearrange bookings after a delayed connection. So I built it.


The Stack at a Glance

Layer Technology
Frontend & Backend Next.js 15 (App Router)
Auth NextAuth v5 (JWT, Credentials)
ORM Prisma 5 with driverAdapters
Database Amazon Aurora PostgreSQL
DB Connection pg pool + @prisma/adapter-pg
IAM Auth @aws-sdk/rds-signer
Hosting Vercel
UI Tailwind CSS + shadcn/ui + Framer Motion
Data fetching TanStack Query + React Hook Form

Why Aurora PostgreSQL?

When the hackathon gave three database options — Aurora PostgreSQL, Aurora DSQL, and DynamoDB — the choice for a travel booking app was straightforward: relational data all the way.

Trips, flights, hotels, and transport bookings are deeply relational. A trip has many bookings. A booking references a specific flight, which belongs to an airline, which operates between two airports. A payment is tied to a booking. Notifications reference trips and users. Trying to model this in a document store would mean either deeply nested documents or endless manual joins in application code. A proper relational schema handles it cleanly.

Aurora PostgreSQL gave me everything I needed:

  • Full PostgreSQL compatibility — Prisma works with it out of the box.
  • High availability — Multi-AZ replication without managing it myself.
  • IAM authentication — Token-based DB auth without embedding a static password anywhere.
  • Scalable — Aurora scales read capacity independently; important for a search-heavy app like this.
  • Extensions — I used uuid-ossp, citext, and pg_stat_statements for UUIDs, case-insensitive text matching on emails, and query performance monitoring.

Designing the Database Schema

The schema covers seven main domains:

Users & Profiles
Trips
Flights → Flight Bookings
Hotels → Hotel Bookings
Transport Providers → Transport Bookings
Payments
Notifications
Enter fullscreen mode Exit fullscreen mode

A few design decisions worth calling out:

Soft deletes everywhere. Every user-facing model has a deletedAt field. Booking history and audit trails matter in a travel context — you can't just hard-delete a payment record.

citext for emails. Email addresses are case-insensitive by convention but not by default in PostgreSQL. Using the citext extension means User@Example.com and user@example.com are treated as the same value at the database level, not in application code.

UUID primary keys. All IDs are uuid_generate_v4() defaults. No sequential integers that could be enumerated or guessed via the API.

Enum types. Booking statuses, payment statuses, trip types, and user roles are all PostgreSQL enums. The database enforces valid state values — the application layer can't accidentally insert a typo.


Connecting to Aurora: IAM Authentication

This was the most interesting engineering challenge. Rather than embedding a static database password, I wanted to use AWS IAM token authentication — Aurora generates a short-lived signed token that acts as the password, which is refreshed every 15 minutes.

Here's the core of how it works in lib/prisma.ts:

async function createIamClient(): Promise<PrismaClient> {
  const signer = new Signer({
    region: process.env.AWS_REGION,
    hostname: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    username: process.env.DB_USER,
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    },
  })

  const token = await signer.getAuthToken()

  const pool = new Pool({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: token,           // IAM token used as the password
    database: process.env.DB_NAME,
    ssl: { rejectUnauthorized: false },
  })

  const adapter = new PrismaPg(pool)
  return new PrismaClient({ adapter })
}
Enter fullscreen mode Exit fullscreen mode

The client is cached on the global object with a 14-minute expiry — just under the 15-minute token TTL — and recreated automatically before it expires:

if (g.__prismaClient && now < (g.__prismaExpiry ?? 0)) {
  return g.__prismaClient   // reuse cached client
}
// otherwise: generate a new IAM token and reconnect
Enter fullscreen mode Exit fullscreen mode

The exported prisma is a Proxy object that transparently awaits the async client on every call, so the rest of the codebase uses it like a normal synchronous Prisma instance.

If you're running without IAM auth (local dev with a direct connection string), you just set DB_IAM_AUTH=false and it falls back to a standard PrismaClient backed by DATABASE_URL.


The API Layer

The app exposes a clean set of REST endpoints under /api:

POST  /api/auth/register
GET   /api/flights/search
GET   /api/flights/[id]
POST  /api/flights/[id]/book
GET   /api/flights/bookings
GET   /api/hotels/search
GET   /api/hotels/[id]
POST  /api/hotels/[id]/book
GET   /api/hotels/bookings
GET   /api/trips
POST  /api/trips
GET   /api/trips/[id]
GET   /api/trips/[id]/timeline
GET   /api/transport
POST  /api/transport
POST  /api/payments
GET   /api/notifications
PATCH /api/notifications/[id]/read
GET   /api/airports
POST  /api/promotions/validate
GET   /api/profile
PUT   /api/profile
Enter fullscreen mode Exit fullscreen mode

Each route is a thin handler that validates input with Zod and delegates to a domain service (flight.service.ts, hotel.service.ts, etc.) which executes Prisma queries. This keeps route files small and makes the business logic testable in isolation.


Authentication with NextAuth v5

Auth runs on NextAuth v5 (beta) with a Credentials provider. On login, the server:

  1. Validates the submitted email/password with Zod.
  2. Looks up the user in Aurora via Prisma (prisma.user.findFirst).
  3. Compares the submitted password against the stored bcrypt hash.
  4. Returns a JWT session containing the user's ID and role.
export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsed = credentialsSchema.safeParse(credentials)
        if (!parsed.success) return null

        const user = await prisma.user.findFirst({
          where: { email: { equals: parsed.data.email, mode: 'insensitive' }, deletedAt: null },
          include: { profile: true },
        })

        if (!user || !user.passwordHash) return null
        const valid = await compare(parsed.data.password, user.passwordHash)
        if (!valid) return null

        return { id: user.id, email: user.email, role: user.role, ... }
      },
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Route protection happens in middleware.ts, which checks the session before any protected path (/dashboard, /trips, /flights, etc.) reaches its page or API handler.


Deploying to Vercel

Deploying a Next.js app to Vercel is straightforward, but a Prisma-backed app has one extra requirement: the Prisma client needs to be generated from the schema during the build step. The default next build script doesn't do this.

The fix is a one-line change to package.json:

"build": "prisma generate && next build"
Enter fullscreen mode Exit fullscreen mode

This ensures Vercel generates the typed Prisma client before the TypeScript compilation starts.

The environment variables that need to be set in the Vercel dashboard:

DATABASE_URL
NEXTAUTH_SECRET
NEXTAUTH_URL
DB_IAM_AUTH
DB_HOST
DB_PORT
DB_USER
DB_NAME
AWS_REGION
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
NEXT_PUBLIC_APP_URL
Enter fullscreen mode Exit fullscreen mode

What I Learned

IAM token rotation is elegant but has edge cases. The 15-minute token TTL meant I had to think carefully about long-lived serverless function instances re-using a stale connection. The 14-minute cache expiry with automatic reconnection handles this cleanly, but it's not something you'd think about with a traditional always-on server.

The Prisma adapter approach is worth the setup. Using @prisma/adapter-pg with a pg.Pool instead of Prisma's built-in connection pooling gives you much more control — especially important when the "password" is a dynamically generated token.

Soft deletes need discipline. Once you add deletedAt to a model, every query that should only return active records needs a where: { deletedAt: null } clause. I handled this at the service layer so it never leaks into route handlers, but it's easy to miss one.

Aurora PostgreSQL extensions are powerful. citext alone saved me from a whole class of subtle auth bugs where email casing could cause a user to fail login or accidentally create a duplicate account.


Architecture

The full architecture diagram is included in the repository (architecture.png), showing how the browser, Vercel platform, Next.js App Router, NextAuth, Prisma, and Aurora PostgreSQL all connect.


Wrapping Up

Buddy started as a frustration with how fragmented travel planning tools are. The H0: Hack the Zero Stack hackathon gave me the right constraints to build it properly — Aurora PostgreSQL for the data layer, Vercel for deployment — and pushed me to solve real infrastructure problems like IAM authentication and Prisma on serverless.

The result is a full-stack travel app that I'd actually use. Which, honestly, is the best thing you can say about something you built in a hackathon.


Built for the H0: Hack the Zero Stack with Vercel v0 and AWS Databases hackathon. Database: Amazon Aurora PostgreSQL. Hosting: Vercel.

Top comments (0)