Documentation

Build an events app

A multi-module conference/community app with events, sessions, attendees, chat, and a small storefront — the event_app pattern.

An events app pulls together more AppEngine modules than any other vertical. You're booking sessions, selling tickets, gating content by ticket type, hosting a discussion forum, running 1:1 networking, sending push notifications, and probably running an in-person scanner at the door. This tutorial walks through the event_app pattern (Flutter + AppEngine) and translates the wiring to a web stack.

What you're building

A conference/community app with:

  • Public event browsing — landing pages, agenda, speakers
  • Ticketing + checkout — multiple ticket types, discount codes, attendee registration
  • Authenticated attendee shell — session schedule, my tickets, networking, chat
  • Community feed — posts, announcements, replies
  • In-app messaging — DMs and chat-rooms via the chat gateway
  • Admin tools — check-in, attendee list, content moderation

Five AppEngine modules carry it: events, storefront (for tickets), community (posts and connections), chat, and CRM (attendee records).

Module map

What you seeAppEngine endpoint cluster
Event landingclient/events/{id}
Ticket types + checkoutclient/events/{id}/ticket-types, storefront/checkout-cart
Schedule + sessionsclient/events/{id}/sessions, client/events/{id}/schedule
Speakers, sponsorsclient/events/{id}/participants?type=speaker
Community feeddata/post, data/post-comment
ChatSocket.IO via ChatGateway
My ticketsdata/order filtered by customerId
Check-inclient/events/{id}/check-in

Built-in collections (post, order, ticket) plus the client/events/* shortcut endpoints — no custom collections needed for the basic app.

Prerequisites

  • AppEngine instance, org id
  • Next.js 16 + Tailwind project (web) or a Flutter project (mobile)
  • Stripe set up for ticket payments
  • Push notification keys (FCM for Android, APNs for iOS) if mobile

Step-by-step

  1. 1

    Create the event

    In the admin UI, Events → New event. The minimum:

    // POST /repository/create/event
    {
      "data": {
        "name": "DevCon 2026",
        "startDate": "2026-09-15T09:00:00Z",
        "endDate": "2026-09-17T18:00:00Z",
        "venue": { "name": "Moscone West", "city": "San Francisco" },
        "description": "...",
        "coverImage": "https://...",
        "ticketTypes": [
          { "id": "early", "name": "Early bird", "price": 299, "available": 200 },
          { "id": "regular", "name": "Regular", "price": 499, "available": 500 },
          { "id": "vip", "name": "VIP + workshops", "price": 999, "available": 50 }
        ],
        "state": "draft"
      }
    }
    

    Flip state to published when you're ready. Until then, client/events/browse won't return it.

    Sessions are nested children — data/event-session records with eventId pointing at the parent. Add tracks and rooms via the admin UI; the schedule endpoint stitches it together.

  2. 2

    Public event landing page

    Web side. A server component pulls the event detail and renders.

    // src/app/events/[id]/page.tsx
    import { eventsAPI } from '@/lib/events-api';
    
    export default async function EventPage({
      params,
    }: {
      params: Promise<{ id: string }>;
    }) {
      const { id } = await params;
      const detail = await eventsAPI.getEventDetail(id);
      if (!detail) return <div>Not found</div>;
    
      const { event, ticketTypes } = detail;
    
      return (
        <div className="mx-auto max-w-4xl py-12">
          <img src={event.coverImage} alt="" className="aspect-video rounded-lg object-cover" />
          <h1 className="mt-6 text-3xl font-bold">{event.name}</h1>
          <p className="text-gray-500">
            {new Date(event.startDate).toLocaleDateString()} — {event.venue.name}
          </p>
          <p className="mt-4">{event.description}</p>
          <h2 className="mt-12 text-xl font-semibold">Tickets</h2>
          <div className="mt-4 grid gap-4">
            {ticketTypes.map((t) => (
              <TicketCard key={t.id} ticketType={t} eventId={id} />
            ))}
          </div>
        </div>
      );
    }
    

    eventsAPI is the wrapper from base-app/src/lib/events-api.ts — copy it; it's already typed against client/events/*.

  3. 3

    Ticket purchase flow

    Tickets are products with extra metadata. The cart endpoint accepts ticket types like any other line item:

    // src/lib/ticket-checkout.ts
    import { storefrontAPI } from '@/lib/storefront-api';
    
    export async function buyTicket(eventId: string, ticketTypeId: string, quantity: number) {
      const cart = await storefrontAPI.addToCart({
        productId: `ticket:${eventId}:${ticketTypeId}`,
        quantity,
        metadata: { eventId, ticketTypeId },
      });
      return storefrontAPI.startCheckout(cart.id);
    }
    

    The product id ticket:{eventId}:{ticketTypeId} is a synthetic id AppEngine resolves via the events module — no need to create a product record per ticket type. Stock is decremented on ticketTypes[].available.

    The rest of the checkout is the storefront tutorial — payment intent, success page, order record. The order's metadata.eventId and metadata.ticketTypeId is what later attaches the attendee to the event.

  4. 4

    Issue tickets and confirmation

    After payment.succeeded, an AppEngine workflow:

    1. Creates a ticket record per quantity, with a unique code (UUID or alphanumeric).
    2. Generates a QR code (encoded code) on AppEngine and stores the URL.
    3. Sends an email with the ticket attachment.
    4. Sends a push notification "Welcome to DevCon!".
    5. Adds the attendee to the event's attendees association.

    You don't write any of that in your Next.js app — it's a workflow defined once. See Automation overview.

  5. 5

    Attendee shell — schedule

    Once authenticated and registered, the attendee sees their schedule. Two queries: the event's full session list, and the attendee's bookmarks (sessions they've added).

    // src/app/(attendee)/schedule/page.tsx
    import { eventsAPI } from '@/lib/events-api';
    import { repositoryAPI } from '@/lib/repository-api';
    
    export default async function SchedulePage() {
      const event = await getCurrentEvent();
      const [sessions, bookmarks] = await Promise.all([
        eventsAPI.getEventSessions(event.id),
        repositoryAPI.list('bookmark', { filter: { eventId: event.id, customerId: 'self' } }),
      ]);
    
      const bookmarkedIds = new Set(bookmarks.map((b: any) => b.data.sessionId));
    
      return (
        <Schedule
          sessions={sessions}
          bookmarkedIds={bookmarkedIds}
        />
      );
    }
    

    'self' resolves on the server to the authenticated customer's id. You don't need to thread the customer id through your code.

    Bookmarking a session is one POST:

    await repositoryAPI.create('bookmark', {
      eventId: event.id,
      sessionId: 'session-id',
      customerId: 'self',
    });
    

    Workflow on bookmark.created adds it to the attendee's calendar feed and (optionally) sends a 15-minute-before reminder push.

  6. 6

    Community feed

    Posts use the built-in post collection scoped to the event. Filter by eventId and order by date.

    // src/app/(attendee)/feed/page.tsx
    import { repositoryAPI } from '@/lib/repository-api';
    
    export default async function FeedPage() {
      const event = await getCurrentEvent();
      const posts = await repositoryAPI.list('post', {
        filter: { eventId: event.id, state: 'published' },
        sort: '-createdate',
        limit: 50,
      });
    
      return <Feed posts={posts} />;
    }
    

    A new-post form is just a write:

    await repositoryAPI.create('post', {
      eventId,
      content: text,
      attachments: imageUrls,
      authorId: 'self',
      state: 'published',
    });
    

    Likes and comments are nested collections (post-like, post-comment). The community module wires up notification fan-out — when somebody likes your post, a workflow fires a push.

  7. 7

    In-app chat

    The chat module covers DMs, group rooms, and the event-wide channel. From the Next.js client:

    'use client';
    import { useEffect } from 'react';
    import { io } from 'socket.io-client';
    
    export function EventChat({ eventId, conversationId }: Props) {
      useEffect(() => {
        const socket = io(process.env.NEXT_PUBLIC_APPENGINE_URL!, {
          auth: { token: getToken(), orgId: getOrgId() },
        });
        socket.emit('join', { conversationId });
        socket.on('message', (msg) => appendMessage(msg));
        return () => { socket.disconnect(); };
      }, [conversationId]);
    
      // ...
    }
    

    Or — for a turnkey UI — drop in the chat-client widget. See Embed the chat widget. For an event the widget points at a special eventId-scoped config and the conversations show up filtered.

  8. 8

    Networking — find people

    The participants endpoint returns everyone with a role on the event (speakers, sponsors, attendees if set to public). Filter by interests for matchmaking.

    const speakers = await eventsAPI.getEventParticipants(eventId, { role: 'speaker' });
    const peopleLikeMe = await eventsAPI.getEventParticipants(eventId, {
      role: 'attendee',
      interests: ['typescript', 'distributed-systems'],
    });
    

    A "request to connect" is a write to connection with state: 'pending'; accepting flips it to accepted. The chat gateway lets connected users DM.

  9. 9

    Check-in

    At the door, an admin user opens a check-in screen. They scan the attendee's QR (the ticket code) and post:

    await client.processRequest('post', `client/events/${eventId}/check-in`, {
      ticketCode: scannedCode,
    });
    

    AppEngine validates the code, marks the ticket state: 'used', and writes a check-in-event for the audit trail. If the code is reused, the second call fails with 409.

  10. 10

    Push notifications

    Register the device on first login:

    await client.processRequest('post', 'data/push-device', {
      data: {
        token: fcmToken,
        platform: 'web', // or 'ios' / 'android'
        customerId: 'self',
        eventId: currentEventId,
      },
    });
    

    Workflows on event.starting-soon, session.starting, post.replied-to send notifications via AppEngine's push module — no client code beyond registration.

Mobile mirror

event_app (Flutter) is the canonical mobile reference. Screens map directly to web routes:

Flutter screenWeb route
customer_home_screen.dart/ (event landing)
events_screen.dart/events
event_detail_screen.dart/events/[id]
schedule_screen.dart/(attendee)/schedule
session_detail_screen.dart/(attendee)/schedule/[sessionId]
feed_screen.dart/(attendee)/feed
people_screen.dart/(attendee)/people
chat_screen.dartembedded chat widget
inbox_screen.dart/(attendee)/inbox
ticket_screen.dart/(attendee)/tickets
qr_profile_screen.dart/(attendee)/profile/qr

Service layer (event_app/lib/services/):

  • api_service.dart — HTTP client
  • community_chat_service.dart — WebSocket wrapper
  • media_upload_service.dart — uploads
  • activity_tracking_service.dart — analytics

If you're building both web and mobile, prototype on mobile (the screens are pre-built) and port routes one at a time.

What's next