Node Next.js PostgreSQL event tickets

A workshop ticket site shows live availability, accepts orders for up to six seats, and issues a ticket code after checkout. Tokay runs the Next.js app as one Web Service with PostgreSQL, so orders and seat counts survive every deployment.

Last updated

You can try the complete experience without opening a payment account. The built in free test mode follows the same order and ticket path, while optional Stripe test keys let you connect a real hosted checkout later.

Runtime
Node.js
Framework
Next.js
Service types
Web Service
Resources
PostgreSQL
Required secrets
None

What it does

The event page shows the workshop date, venue, price, and current number of seats left.

Use the minus and plus buttons to choose between one and six seats. Press Complete free test order and the next page gives you a ticket code with your reservation details.

When Stripe test mode is connected, the button changes to Continue to Stripe test checkout. The app waits for Stripe's signed confirmation before it issues that ticket.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Finish setup in Tokay

The ticket app needs PostgreSQL to keep orders and ticket codes. Open Environment and find DATABASE_URL under From your infrastructure. Confirm the suggested database, leave its name as app, and finish with Confirm.

Finish the deployment tracker once DATABASE_URL shows Good to go. Choose Go Live if Tokay asks. The live URL at the top of the finished Service opens the event page.

Try it

  1. Choose two seats and press Complete free test order.
  2. Save the ticket page URL, then deploy the same code again.
  3. Open that URL. Your ticket code remains, and the event page still counts both seats.

Add Stripe test checkout

Use the live Service URL that already works in free test mode.

  1. Create a Stripe test mode secret key.
  2. In Stripe, add a test webhook endpoint using your live URL followed by /api/stripe/webhook.
  3. Subscribe that endpoint to checkout.session.completed, then copy the webhook signing secret Stripe creates for it.
  4. Open the Service's Environment page. Find STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET under From your code.
  5. Choose Set shared value for each one, paste the matching value, and save it.
  6. Deploy the Service again.

The checkout button will now open Stripe in test mode. Complete a payment with a Stripe test card, then return to the ticket page. The app waits for Stripe's signed confirmation before issuing the ticket.

How it works

You get a current seat count in the first page response because Next.js reads PostgreSQL on the server. The browser handles quantity changes and starts checkout, while the database keeps each order and ticket code.

Drizzle keeps the schema with the source. Tokay rehearses its committed migration before applying it. Free test orders complete in one transaction, while Stripe orders remain pending until a valid signed webhook arrives.

Secrets

Name Required Purpose
STRIPE_SECRET_KEY No Enables Stripe test checkout when the webhook secret is also set.
STRIPE_WEBHOOK_SECRET No Verifies Stripe completion events before a ticket is issued.

Grow from here

Try the PostgreSQL task tracker when you want a smaller app with a detailed JSON API.

app/api/checkout/route.ts Raw

import { randomUUID } from "crypto";
import Stripe from "stripe";
import { pool } from "../../../db/client";

export const dynamic = "force-dynamic";

export async function POST(request: Request) {
  const input = await request.json().catch(() => null);
  const quantity = Number(input?.quantity);
  if (!input?.eventId || !Number.isInteger(quantity) || quantity < 1 || quantity > 6) {
    return Response.json({ error: "Choose between one and six tickets." }, { status: 400 });
  }
  const eventResult = await pool.query("SELECT id, name, price_cents FROM events WHERE id = $1", [input.eventId]);
  const event = eventResult.rows[0];
  if (!event) return Response.json({ error: "That event was not found." }, { status: 404 });

  const orderId = randomUUID();
  const amountCents = event.price_cents * quantity;
  // Stripe is optional. Without both secrets, checkout completes as a free test order.
  const stripeKey = process.env.STRIPE_SECRET_KEY?.trim() || "";
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() || "";
  if (!stripeKey || !webhookSecret) {
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      await client.query(
        "INSERT INTO orders (id, event_id, quantity, amount_cents, status, checkout_provider) VALUES ($1, $2, $3, $4, 'paid', 'simulated')",
        [orderId, event.id, quantity, amountCents],
      );
      await client.query(
        "INSERT INTO tickets (id, order_id, code) VALUES ($1, $2, $3)",
        [randomUUID(), orderId, `BOOK-${randomUUID().slice(0, 8).toUpperCase()}`],
      );
      await client.query("COMMIT");
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
    return Response.json({ url: `/tickets/${orderId}` });
  }

  await pool.query(
    "INSERT INTO orders (id, event_id, quantity, amount_cents, status, checkout_provider) VALUES ($1, $2, $3, $4, 'pending', 'stripe')",
    [orderId, event.id, quantity, amountCents],
  );
  const stripe = new Stripe(stripeKey);
  const origin = new URL(request.url).origin;
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{
      quantity,
      price_data: {
        currency: "usd",
        unit_amount: event.price_cents,
        product_data: { name: event.name },
      },
    }],
    metadata: { orderId },
    success_url: `${origin}/tickets/${orderId}?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: origin,
  });
  await pool.query("UPDATE orders SET stripe_session_id = $1 WHERE id = $2", [session.id, orderId]);
  return Response.json({ url: session.url });
}

app/api/health/route.ts Raw

export function GET() {
  return Response.json({ ok: true });
}

app/api/stripe/webhook/route.ts Raw

import { randomUUID } from "crypto";
import Stripe from "stripe";
import { pool } from "../../../../db/client";

export async function POST(request: Request) {
  // Both values are optional because the app has a complete free test order mode.
  const stripeKey = process.env.STRIPE_SECRET_KEY?.trim() || "";
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim() || "";
  if (!stripeKey || !webhookSecret) return new Response("Stripe test mode is not configured.", { status: 503 });
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Stripe signature missing.", { status: 400 });

  const stripe = new Stripe(stripeKey);
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(await request.text(), signature, webhookSecret);
  } catch {
    return new Response("Stripe signature invalid.", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    const orderId = session.metadata?.orderId;
    if (orderId) {
      const client = await pool.connect();
      try {
        await client.query("BEGIN");
        await client.query("UPDATE orders SET status = 'paid' WHERE id = $1", [orderId]);
        await client.query(
          "INSERT INTO tickets (id, order_id, code) VALUES ($1, $2, $3) ON CONFLICT (order_id) DO NOTHING",
          [randomUUID(), orderId, `BOOK-${randomUUID().slice(0, 8).toUpperCase()}`],
        );
        await client.query("COMMIT");
      } catch (error) {
        await client.query("ROLLBACK");
        throw error;
      } finally {
        client.release();
      }
    }
  }
  return Response.json({ received: true });
}

app/tickets/[id]/page.tsx Raw

import Link from "next/link";
import { pool } from "../../../db/client";

export const dynamic = "force-dynamic";

type TicketRow = {
  order_id: string;
  quantity: number;
  amount_cents: number;
  status: string;
  checkout_provider: string;
  code: string | null;
  name: string;
  venue: string;
  starts_at: Date;
};

export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const result = await pool.query<TicketRow>(`
    SELECT o.id AS order_id, o.quantity, o.amount_cents, o.status, o.checkout_provider,
           t.code, e.name, e.venue, e.starts_at
    FROM orders o
    JOIN events e ON e.id = o.event_id
    LEFT JOIN tickets t ON t.order_id = o.id
    WHERE o.id = $1
  `, [id]);
  const ticket = result.rows[0];
  if (!ticket) return <main className="ticket"><h1>Ticket not found</h1><Link href="/">Return to the workshop</Link></main>;
  if (ticket.status !== "paid" || !ticket.code) {
    return <main className="ticket"><p className="kicker">Payment pending</p><h1>Your ticket is almost ready.</h1><p>Stripe has not confirmed this test payment yet. Refresh after the webhook arrives.</p></main>;
  }
  return (
    <main className="ticket">
      <p className="kicker">You have a place at the table</p>
      <h1>{ticket.name}</h1>
      <div className="ticket-code"><span>Ticket code</span><strong>{ticket.code}</strong></div>
      <p>{ticket.quantity} {ticket.quantity === 1 ? "seat" : "seats"} at {ticket.venue}</p>
      <p>{new Intl.DateTimeFormat("en", { dateStyle: "full", timeStyle: "short", timeZone: "America/New_York" }).format(ticket.starts_at)}</p>
      <p className="mode">{ticket.checkout_provider === "simulated" ? "Completed in free test order mode." : "Completed through Stripe test mode."}</p>
      <Link href="/">Back to the event</Link>
    </main>
  );
}

app/checkout.tsx Raw

"use client";

import { useState } from "react";

export function Checkout({ eventId, priceCents, stripeReady }: { eventId: string; priceCents: number; stripeReady: boolean }) {
  const [quantity, setQuantity] = useState(1);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  async function submit() {
    setBusy(true);
    setError("");
    try {
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ eventId, quantity }),
      });
      const result = await response.json();
      if (!response.ok) throw new Error(result.error || "Checkout could not start.");
      window.location.assign(result.url);
    } catch (problem) {
      setError(problem instanceof Error ? problem.message : "Checkout could not start.");
      setBusy(false);
    }
  }

  return (
    <section className="checkout">
      <span className="eyebrow">Choose your seats</span>
      <div className="quantity">
        <button aria-label="Remove one ticket" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
        <strong>{quantity}</strong>
        <button aria-label="Add one ticket" onClick={() => setQuantity(Math.min(6, quantity + 1))}>+</button>
      </div>
      <div className="total">
        <span>Total</span>
        <strong>${((priceCents * quantity) / 100).toFixed(2)}</strong>
      </div>
      <button className="buy" disabled={busy} onClick={submit}>
        {busy ? "Preparing your ticket…" : stripeReady ? "Continue to Stripe test checkout" : "Complete free test order"}
      </button>
      <p className="mode">{stripeReady ? "Stripe test mode is connected." : "Free test order mode. No payment key needed."}</p>
      {error && <p className="error">{error}</p>}
    </section>
  );
}

app/layout.tsx Raw

import type { Metadata } from "next";
import "./styles.css";

export const metadata: Metadata = {
  title: "Make a Book by Hand",
  description: "Tickets for one afternoon of bookmaking",
};

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

app/page.tsx Raw

import { pool } from "../db/client";
import { Checkout } from "./checkout";

export const dynamic = "force-dynamic";

type EventRow = {
  id: string;
  name: string;
  description: string;
  venue: string;
  starts_at: Date;
  price_cents: number;
  capacity: number;
  sold: string;
};

export default async function EventPage() {
  const result = await pool.query<EventRow>(`
    SELECT e.*, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.quantity ELSE 0 END), 0)::text AS sold
    FROM events e
    LEFT JOIN orders o ON o.event_id = e.id
    GROUP BY e.id
    ORDER BY e.starts_at
    LIMIT 1
  `);
  const event = result.rows[0];
  if (!event) return <main className="empty">The workshop is being prepared. Refresh in a moment.</main>;
  const date = new Intl.DateTimeFormat("en", { dateStyle: "full", timeStyle: "short", timeZone: "America/New_York" }).format(event.starts_at);
  const seatsLeft = event.capacity - Number(event.sold);
  const stripeReady = Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_WEBHOOK_SECRET);

  return (
    <main>
      <div className="paper" aria-hidden="true"><span>fold</span><span>stitch</span><span>case</span></div>
      <article>
        <p className="kicker">One afternoon workshop</p>
        <h1>{event.name}</h1>
        <p className="lede">{event.description}</p>
        <dl>
          <div><dt>When</dt><dd>{date}</dd></div>
          <div><dt>Where</dt><dd>{event.venue}</dd></div>
          <div><dt>Seats left</dt><dd>{seatsLeft} of {event.capacity}</dd></div>
        </dl>
      </article>
      <Checkout eventId={event.id} priceCents={event.price_cents} stripeReady={stripeReady} />
      <p className="ssr-proof">Rendered from PostgreSQL on the server.</p>
    </main>
  );
}

app/styles.css Raw

:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #eee9df; color: #29261f; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
main { width: min(1120px, calc(100% - 36px)); margin: 38px auto 80px; display: grid; grid-template-columns: 0.72fr 1.3fr 0.85fr; gap: 28px; align-items: start; }
.paper { min-height: 590px; padding: 28px; display: flex; flex-direction: column; justify-content: space-between; border-radius: 4px 34px 6px 5px; background: #a94431; color: #f5dfba; box-shadow: 0 20px 50px #40352a2b; font: 500 1rem Georgia, serif; text-transform: uppercase; letter-spacing: 0.18em; }
article { padding: 35px 6px; }
.kicker, .eyebrow { color: #9a4935; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { max-width: 680px; margin: 12px 0 22px; font: 500 clamp(3.5rem, 8vw, 7.6rem)/0.88 Georgia, serif; letter-spacing: -0.055em; }
.lede { max-width: 650px; color: #625b50; font: 1.2rem/1.7 Georgia, serif; }
dl { margin: 38px 0 0; border-top: 1px solid #bdb3a4; }
dl div { display: grid; grid-template-columns: 90px 1fr; gap: 16px; padding: 14px 0; border-bottom: 1px solid #cfc6b8; }
dt { color: #82786a; } dd { margin: 0; font-weight: 700; }
.checkout { margin-top: 56px; padding: 24px; border: 1px solid #c9c0b2; border-radius: 18px; background: #f7f4ed; box-shadow: 0 15px 40px #40352a15; }
.quantity { display: grid; grid-template-columns: 42px 1fr 42px; align-items: center; margin: 16px 0; text-align: center; }
.quantity button { height: 42px; border: 1px solid #b8ac9d; border-radius: 50%; background: white; cursor: pointer; }
.quantity strong { font-size: 1.5rem; }
.total { display: flex; justify-content: space-between; padding: 15px 0; border-top: 1px solid #d7cfc3; font-size: 1.15rem; }
.buy { width: 100%; min-height: 50px; border: 0; border-radius: 10px; background: #2f5b4d; color: white; font-weight: 800; cursor: pointer; }
.buy:disabled { opacity: 0.65; }
.mode, .ssr-proof { color: #766d60; font-size: 0.86rem; line-height: 1.5; }
.error { color: #a12f25; }
.ssr-proof { grid-column: 2; }
.ticket { min-height: 100vh; display: block; max-width: 760px; margin: 0 auto; padding: 14vh 30px; }
.ticket h1 { font-size: clamp(3rem, 9vw, 6rem); }
.ticket-code { margin: 34px 0; padding: 26px; border: 2px dashed #a94431; background: #f7f4ed; }
.ticket-code span, .ticket-code strong { display: block; }
.ticket-code strong { margin-top: 8px; font: 700 2rem ui-monospace, monospace; letter-spacing: 0.08em; }
a { color: #2f5b4d; font-weight: 800; }
.empty { display: block; padding: 20vh 30px; }
@media (max-width: 850px) { main { grid-template-columns: 1fr; } .paper { min-height: 130px; flex-direction: row; } article { padding: 10px 0; } .checkout { margin-top: 0; } .ssr-proof { grid-column: 1; } }

db/client.ts Raw

import { Pool } from "pg";

declare global {
  var ticketPool: Pool | undefined;
}

export const pool = global.ticketPool || new Pool({ connectionString: process.env.DATABASE_URL });

if (process.env.NODE_ENV !== "production") global.ticketPool = pool;

db/schema.ts Raw

import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";

export const events = pgTable("events", {
  id: uuid("id").primaryKey(),
  name: text("name").notNull(),
  description: text("description").notNull(),
  venue: text("venue").notNull(),
  startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
  priceCents: integer("price_cents").notNull(),
  capacity: integer("capacity").notNull(),
});

export const orders = pgTable("orders", {
  id: uuid("id").primaryKey(),
  eventId: uuid("event_id").notNull().references(() => events.id),
  quantity: integer("quantity").notNull(),
  amountCents: integer("amount_cents").notNull(),
  status: text("status").notNull(),
  checkoutProvider: text("checkout_provider").notNull(),
  stripeSessionId: text("stripe_session_id"),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const tickets = pgTable("tickets", {
  id: uuid("id").primaryKey(),
  orderId: uuid("order_id").notNull().unique().references(() => orders.id),
  code: text("code").notNull().unique(),
  issuedAt: timestamp("issued_at", { withTimezone: true }).notNull().defaultNow(),
});

drizzle/meta/_journal.json Raw

{
  "version": "7",
  "dialect": "postgresql",
  "entries": [
    {
      "idx": 0,
      "version": "7",
      "when": 1784332800000,
      "tag": "0000_create_event_tickets",
      "breakpoints": true
    }
  ]
}

drizzle/0000_create_event_tickets.sql Raw

CREATE TABLE "events" (
  "id" uuid PRIMARY KEY,
  "name" text NOT NULL,
  "description" text NOT NULL,
  "venue" text NOT NULL,
  "starts_at" timestamptz NOT NULL,
  "price_cents" integer NOT NULL CHECK ("price_cents" >= 0),
  "capacity" integer NOT NULL CHECK ("capacity" > 0)
);

CREATE TABLE "orders" (
  "id" uuid PRIMARY KEY,
  "event_id" uuid NOT NULL REFERENCES "events" ("id"),
  "quantity" integer NOT NULL CHECK ("quantity" BETWEEN 1 AND 6),
  "amount_cents" integer NOT NULL CHECK ("amount_cents" >= 0),
  "status" text NOT NULL CHECK ("status" IN ('pending', 'paid')),
  "checkout_provider" text NOT NULL CHECK ("checkout_provider" IN ('simulated', 'stripe')),
  "stripe_session_id" text,
  "created_at" timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX "orders_stripe_session_id_unique" ON "orders" ("stripe_session_id") WHERE "stripe_session_id" IS NOT NULL;

CREATE TABLE "tickets" (
  "id" uuid PRIMARY KEY,
  "order_id" uuid NOT NULL UNIQUE REFERENCES "orders" ("id"),
  "code" text NOT NULL UNIQUE,
  "issued_at" timestamptz NOT NULL DEFAULT now()
);

drizzle.config.ts Raw

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL || "postgresql://build:build@localhost/build",
  },
});

next-env.d.ts Raw

/// <reference types="next" />
/// <reference types="next/image-types/global" />

// This file is maintained by Next.js.

package.json Raw

{
  "name": "node-nextjs-postgres-event-tickets",
  "version": "1.0.0",
  "private": true,
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "build": "next build",
    "start": "node server.js"
  },
  "dependencies": {
    "drizzle-orm": "0.45.2",
    "next": "16.2.10",
    "pg": "8.22.0",
    "react": "19.2.7",
    "react-dom": "19.2.7",
    "stripe": "22.3.2"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "@types/pg": "^8.15.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "drizzle-kit": "0.31.10",
    "typescript": "^5.9.0"
  }
}

server.js Raw

const next = require("next");
const { createServer } = require("http");
const { Pool } = require("pg");

const eventId = "e09c2c50-8dd6-4f20-83e3-24c0b6c7c012";

async function seedEvent() {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  try {
    await pool.query(
      `INSERT INTO events (id, name, description, venue, starts_at, price_cents, capacity)
       VALUES ($1, $2, $3, $4, $5, $6, $7)
       ON CONFLICT (id) DO UPDATE SET
         name = EXCLUDED.name,
         description = EXCLUDED.description,
         venue = EXCLUDED.venue,
         starts_at = EXCLUDED.starts_at,
         price_cents = EXCLUDED.price_cents,
         capacity = EXCLUDED.capacity`,
      [
        eventId,
        "Make a Book by Hand",
        "Spend one generous afternoon folding, stitching, and casing a clothbound notebook. Materials, tea, and a patient first lesson are included.",
        "North Window Workshop",
        "2026-10-17T18:00:00.000Z",
        4800,
        120,
      ],
    );
  } finally {
    await pool.end();
  }
}

async function start() {
  await seedEvent();
  const app = next({ dev: false });
  const handle = app.getRequestHandler();
  await app.prepare();
  const port = Number(process.env.PORT || 3000);
  createServer((request, response) => handle(request, response)).listen(port, "0.0.0.0", () => {
    console.log(`Event tickets ready on port ${port}`);
  });
}

start().catch((error) => {
  console.error("Event tickets could not start", error);
  process.exit(1);
});

tsconfig.json Raw

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Built from revision 52c7b7f

Guide: Deploy a Next.js app