Developer Tutorial

Build a complete event website with Next.js and the EventNerds API

Introduction

In this tutorial, you will build a complete event website for a fictitious developer conference called "TechConf 2025". By the end of this tutorial, you will have a fully functional website that displays event details, speakers, sessions, ticket options, and includes an attendee portal.

What you will build

  • A modern, responsive event website with homepage, speakers, schedule, and tickets pages
  • Integration with the EventNerds API to fetch event data dynamically
  • Ticket purchase functionality with registration forms and order creation
  • Attendee portal with magic link authentication and QR code display
  • Production-ready code with TypeScript, error handling, and loading states

What you will learn

  • How to use the EventNerds Developer API to fetch event data
  • Building server components and client components in Next.js 14+ App Router
  • Creating reusable API client functions with proper error handling
  • Implementing authentication flows with magic links
  • Displaying dynamic data with loading states and error boundaries
  • Building responsive UI components with Tailwind CSS
  • Deploying a Next.js application to Vercel

Prerequisites

Before starting this tutorial, you should have:

  • Node.js 18.0 or higher installed on your machine
  • Basic knowledge of React and Next.js
  • Familiarity with TypeScript
  • An EventNerds account and API key (see API Keys documentation)
  • A code editor like VS Code

Note: This tutorial assumes you have already created an event in EventNerds with some sample data (speakers, sessions, tickets). If not, you can use the EventNerds dashboard to create test data before starting.

1

Project setup

First, let's create a new Next.js project and install the required dependencies.

Initialize the project

Open your terminal and run the following commands:

# Create a new Next.js project
npx create-next-app@latest techconf-2025 --typescript --tailwind --app --no-src-dir

# Navigate to the project directory
cd techconf-2025

# Install additional dependencies
npm install lucide-react date-fns qrcode
npm install --save-dev @types/qrcode

When prompted during setup, choose the following options:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Import alias: Yes (default @/*)

Project structure

Your project should have the following structure:

techconf-2025/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── globals.css
│   ├── speakers/
│   │   └── page.tsx
│   ├── schedule/
│   │   └── page.tsx
│   ├── tickets/
│   │   └── page.tsx
│   └── portal/
│       └── page.tsx
├── components/
│   ├── navigation.tsx
│   ├── hero.tsx
│   ├── speaker-card.tsx
│   ├── session-card.tsx
│   └── ticket-card.tsx
├── lib/
│   ├── api-client.ts
│   └── types.ts
├── public/
├── .env.local
├── package.json
├── tsconfig.json
├── tailwind.config.ts
└── next.config.js

Environment variables

Create a .env.local file in the root of your project:

# EventNerds API Configuration
NEXT_PUBLIC_EVENTNERDS_API_KEY=your_api_key_here
NEXT_PUBLIC_EVENT_ID=your_event_id_here
NEXT_PUBLIC_API_BASE_URL=https://eventnerds.com/api/v1/developer

Where to find these values:

  • • API Key: EventNerds Dashboard → Settings → API Keys
  • • Event ID: EventNerds Dashboard → Events → Select Event → Copy ID from URL
2

Fetching event data

Now let's create an API client to interact with the EventNerds API. This client will handle authentication and provide typed functions for fetching data.

Define TypeScript types

Create lib/types.ts:

// Event details are typically hardcoded in your website
// since the Developer API doesn't include an endpoint to fetch event metadata
export interface Event {
  id: string;
  name: string;
  description: string;
  start_date: string;
  end_date: string;
  location: string;
  timezone: string;
  website_url: string | null;
  banner_image_url: string | null;
}

export interface Speaker {
  id: string;
  name: string;
  title: string;
  company: string | null;
  bio: string | null;
  profile_image_url: string | null;
  twitter_handle: string | null;
  linkedin_url: string | null;
  website_url: string | null;
  tags: string[];
}

export interface Track {
  id: string;
  name: string;
  description: string | null;
  color: string;
}

export interface Session {
  id: string;
  title: string;
  description: string | null;
  start_time: string;
  end_time: string;
  room: string | null;
  track: Track | null;
  speakers: Speaker[];
  tags: string[];
}

export interface Ticket {
  id: string;
  name: string;
  description: string | null;
  price: number;
  currency: string;
  quantity_total: number;
  quantity_sold: number;
  sale_start_date: string | null;
  sale_end_date: string | null;
  is_active: boolean;
  features: string[];
}

export interface Order {
  id: string;
  order_number: string;
  total_amount: number;
  currency: string;
  status: string;
  tickets: {
    ticket_id: string;
    quantity: number;
  }[];
}

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  meta?: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

export interface ApiError {
  success: false;
  error: {
    code: string;
    message: string;
    details?: Record<string, unknown>;
  };
}

Create the API client

Create lib/api-client.ts:

import type {
  Speaker,
  Session,
  Ticket,
  Order,
  ApiResponse,
} from './types';

export class EventNerdsAPIError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number
  ) {
    super(message);
    this.name = 'EventNerdsAPIError';
  }
}

export class EventNerdsAPI {
  private baseURL: string;
  private apiKey: string;
  private eventId: string;

  constructor(apiKey: string, eventId: string, baseURL?: string) {
    this.baseURL = baseURL || 'https://eventnerds.com/api/v1/developer';
    this.apiKey = apiKey;
    this.eventId = eventId;
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseURL}${endpoint}`;

    const headers = {
      'Authorization': `Bearer ${this.apiKey}`,
      'Content-Type': 'application/json',
      ...options.headers,
    };

    try {
      const response = await fetch(url, {
        ...options,
        headers,
      });

      const data = await response.json();

      if (!response.ok) {
        throw new EventNerdsAPIError(
          data.error?.message || 'An error occurred',
          data.error?.code || 'UNKNOWN_ERROR',
          response.status
        );
      }

      return data;
    } catch (error) {
      if (error instanceof EventNerdsAPIError) {
        throw error;
      }
      throw new EventNerdsAPIError(
        'Network error occurred',
        'NETWORK_ERROR',
        0
      );
    }
  }

  async getSpeakers(): Promise<Speaker[]> {
    const response = await this.request<ApiResponse<Speaker[]>>(
      `/speakers?event_id=${this.eventId}`
    );
    return response.data;
  }

  async getSessions(): Promise<Session[]> {
    const response = await this.request<ApiResponse<Session[]>>(
      `/sessions?event_id=${this.eventId}`
    );
    return response.data;
  }

  async getTickets(): Promise<Ticket[]> {
    const response = await this.request<ApiResponse<Ticket[]>>(
      `/tickets?event_id=${this.eventId}`
    );
    return response.data;
  }

  async createOrder(orderData: {
    email: string;
    first_name: string;
    last_name: string;
    tickets: { ticket_id: string; quantity: number }[];
    answers?: Record<string, string | string[]>;
  }): Promise<Order> {
    const response = await this.request<ApiResponse<Order>>(
      '/registration',
      {
        method: 'POST',
        body: JSON.stringify({
          event_id: this.eventId,
          ...orderData,
        }),
      }
    );
    return response.data;
  }
}

// Create a singleton instance
export function getAPIClient(): EventNerdsAPI {
  const apiKey = process.env.NEXT_PUBLIC_EVENTNERDS_API_KEY;
  const eventId = process.env.NEXT_PUBLIC_EVENT_ID;
  const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL;

  if (!apiKey || !eventId) {
    throw new Error('Missing API configuration');
  }

  return new EventNerdsAPI(apiKey, eventId, baseURL);
}

Error handling: This API client includes proper error handling with custom error types. Network errors and API errors are handled separately, making debugging easier.

3

Building the homepage

The homepage will display the event details, key statistics, and links to other pages. Let's start by creating the navigation component and hero section.

Create the navigation component

Create components/navigation.tsx:

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, X } from 'lucide-react';
import { useState } from 'react';

export function Navigation() {
  const [isOpen, setIsOpen] = useState(false);
  const pathname = usePathname();

  const links = [
    { href: '/', label: 'Home' },
    { href: '/speakers', label: 'Speakers' },
    { href: '/schedule', label: 'Schedule' },
    { href: '/tickets', label: 'Tickets' },
    { href: '/portal', label: 'Attendee Portal' },
  ];

  return (
    <nav className="bg-background border-b border-border">
      <div className="container mx-auto px-4">
        <div className="flex items-center justify-between h-16">
          <Link href="/" className="text-xl font-bold text-primary">
            TechConf 2025
          </Link>

          {/* Desktop Navigation */}
          <div className="hidden md:flex items-center space-x-8">
            {links.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                className={`text-sm font-medium transition-colors hover:text-primary ${
                  pathname === link.href
                    ? 'text-primary'
                    : 'text-muted-foreground'
                }`}
              >
                {link.label}
              </Link>
            ))}
          </div>

          {/* Mobile Menu Button */}
          <button
            onClick={() => setIsOpen(!isOpen)}
            className="md:hidden p-2"
            aria-label="Toggle menu"
          >
            {isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
          </button>
        </div>

        {/* Mobile Navigation */}
        {isOpen && (
          <div className="md:hidden py-4 space-y-2">
            {links.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                onClick={() => setIsOpen(false)}
                className={`block py-2 text-sm font-medium transition-colors hover:text-primary ${
                  pathname === link.href
                    ? 'text-primary'
                    : 'text-muted-foreground'
                }`}
              >
                {link.label}
              </Link>
            ))}
          </div>
        )}
      </div>
    </nav>
  );
}

Create the hero component

Create components/hero.tsx:

import Link from 'next/link';
import { Calendar, MapPin, Users } from 'lucide-react';
import { format } from 'date-fns';
import type { Event } from '@/lib/types';

interface HeroProps {
  event: Event;
  speakerCount: number;
  sessionCount: number;
}

export function Hero({ event, speakerCount, sessionCount }: HeroProps) {
  const startDate = new Date(event.start_date);
  const endDate = new Date(event.end_date);

  return (
    <div className="relative bg-gradient-to-br from-primary via-primary to-accent text-primary-foreground">
      <div className="container mx-auto px-4 py-20">
        <div className="max-w-4xl mx-auto text-center">
          <h1 className="text-5xl md:text-6xl font-bold mb-6">
            {event.name}
          </h1>
          <p className="text-xl md:text-2xl mb-8 text-primary-foreground/80">
            {event.description}
          </p>

          <div className="flex flex-wrap items-center justify-center gap-6 mb-12">
            <div className="flex items-center gap-2">
              <Calendar className="h-5 w-5" />
              <span className="text-lg">
                {format(startDate, 'MMM d')} - {format(endDate, 'MMM d, yyyy')}
              </span>
            </div>
            <div className="flex items-center gap-2">
              <MapPin className="h-5 w-5" />
              <span className="text-lg">{event.location}</span>
            </div>
            <div className="flex items-center gap-2">
              <Users className="h-5 w-5" />
              <span className="text-lg">
                {speakerCount} Speakers • {sessionCount} Sessions
              </span>
            </div>
          </div>

          <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
            <Link
              href="/tickets"
              className="px-8 py-4 bg-background text-primary rounded-lg font-semibold text-lg hover:bg-primary/10 transition-colors"
            >
              Register Now
            </Link>
            <Link
              href="/schedule"
              className="px-8 py-4 bg-primary/20 backdrop-blur-sm border-2 border-primary-foreground/50 rounded-lg font-semibold text-lg hover:bg-primary/30 transition-colors"
            >
              View Schedule
            </Link>
          </div>
        </div>
      </div>
    </div>
  );
}

Update the layout and homepage

Update app/layout.tsx:

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Navigation } from '@/components/navigation';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'TechConf 2025 - Developer Conference',
  description: 'Join us for three days of cutting-edge tech talks and networking',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Navigation />
        <main>{children}</main>
        <footer className="bg-foreground text-background py-12">
          <div className="container mx-auto px-4 text-center">
            <p>&copy; 2025 TechConf. All rights reserved.</p>
            <p className="text-muted-foreground text-sm mt-2">
              Powered by EventNerds
            </p>
          </div>
        </footer>
      </body>
    </html>
  );
}

Update app/page.tsx:

import { getAPIClient } from '@/lib/api-client';
import { Hero } from '@/components/hero';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import type { Event } from '@/lib/types';

// Hardcode your event details since the Developer API
// doesn't provide an endpoint to fetch event metadata
const event: Event = {
  id: process.env.NEXT_PUBLIC_EVENT_ID!,
  name: 'TechConf 2025',
  description: 'The premier developer conference for tech innovators',
  start_date: '2025-06-15T09:00:00Z',
  end_date: '2025-06-17T18:00:00Z',
  location: 'San Francisco, CA',
  timezone: 'America/Los_Angeles',
  website_url: null,
  banner_image_url: null,
};

export default async function HomePage() {
  const client = getAPIClient();

  try {
    const [speakers, sessions] = await Promise.all([
      client.getSpeakers(),
      client.getSessions(),
    ]);

    return (
      <div>
        <Hero
          event={event}
          speakerCount={speakers.length}
          sessionCount={sessions.length}
        />

        <div className="container mx-auto px-4 py-16">
          <section className="mb-16">
            <h2 className="text-3xl font-bold mb-6 text-center">
              About TechConf 2025
            </h2>
            <div className="max-w-3xl mx-auto text-lg text-muted-foreground space-y-4">
              <p>
                TechConf 2025 is the premier developer conference bringing together
                industry leaders, innovators, and developers from around the world.
              </p>
              <p>
                Join us for three days of inspiring talks, hands-on workshops, and
                networking opportunities that will shape the future of technology.
              </p>
            </div>
          </section>

          <section className="mb-16">
            <div className="flex items-center justify-between mb-6">
              <h2 className="text-3xl font-bold">Featured speakers</h2>
              <Link
                href="/speakers"
                className="flex items-center gap-2 text-primary hover:underline"
              >
                View All
                <ArrowRight className="h-4 w-4" />
              </Link>
            </div>
            <p className="text-muted-foreground mb-6">
              Learn from {speakers.length}+ industry experts and thought leaders
            </p>
            <Link
              href="/speakers"
              className="inline-flex items-center px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
            >
              Meet Our Speakers
            </Link>
          </section>

          <section>
            <div className="flex items-center justify-between mb-6">
              <h2 className="text-3xl font-bold">Schedule</h2>
              <Link
                href="/schedule"
                className="flex items-center gap-2 text-primary hover:underline"
              >
                View Full Schedule
                <ArrowRight className="h-4 w-4" />
              </Link>
            </div>
            <p className="text-muted-foreground mb-6">
              Explore {sessions.length}+ sessions across multiple tracks
            </p>
            <Link
              href="/schedule"
              className="inline-flex items-center px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
            >
              Browse Sessions
            </Link>
          </section>
        </div>
      </div>
    );
  } catch (error) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center">
          <h1 className="text-2xl font-bold text-destructive mb-4">
            Error Loading Event
          </h1>
          <p className="text-muted-foreground">
            Could not load event data. Please check your API configuration.
          </p>
        </div>
      </div>
    );
  }
}
4

Speaker showcase

Let's create a speakers page that displays all speakers in a grid layout with filtering by tags.

Create the speaker card component

Create components/speaker-card.tsx:

import Image from 'next/image';
import { Twitter, Linkedin, Globe } from 'lucide-react';
import type { Speaker } from '@/lib/types';

interface SpeakerCardProps {
  speaker: Speaker;
}

export function SpeakerCard({ speaker }: SpeakerCardProps) {
  return (
    <div className="bg-card rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow">
      <div className="aspect-square relative bg-muted">
        {speaker.profile_image_url ? (
          <Image
            src={speaker.profile_image_url}
            alt={speaker.name}
            fill
            className="object-cover"
          />
        ) : (
          <div className="w-full h-full flex items-center justify-center text-4xl font-bold text-muted-foreground">
            {speaker.name.charAt(0)}
          </div>
        )}
      </div>

      <div className="p-6">
        <h3 className="text-xl font-bold mb-1">{speaker.name}</h3>
        <p className="text-sm text-muted-foreground mb-3">
          {speaker.title}
          {speaker.company && ` at ${speaker.company}`}
        </p>

        {speaker.bio && (
          <p className="text-sm text-foreground mb-4 line-clamp-3">
            {speaker.bio}
          </p>
        )}

        {speaker.tags.length > 0 && (
          <div className="flex flex-wrap gap-2 mb-4">
            {speaker.tags.slice(0, 3).map((tag) => (
              <span
                key={tag}
                className="px-2 py-1 bg-info/10 text-info text-xs rounded"
              >
                {tag}
              </span>
            ))}
          </div>
        )}

        <div className="flex gap-3">
          {speaker.twitter_handle && (
            <a
              href={`https://twitter.com/${speaker.twitter_handle}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-muted-foreground hover:text-info transition-colors"
              aria-label="Twitter"
            >
              <Twitter className="h-5 w-5" />
            </a>
          )}
          {speaker.linkedin_url && (
            <a
              href={speaker.linkedin_url}
              target="_blank"
              rel="noopener noreferrer"
              className="text-muted-foreground hover:text-info transition-colors"
              aria-label="LinkedIn"
            >
              <Linkedin className="h-5 w-5" />
            </a>
          )}
          {speaker.website_url && (
            <a
              href={speaker.website_url}
              target="_blank"
              rel="noopener noreferrer"
              className="text-muted-foreground hover:text-accent-foreground transition-colors"
              aria-label="Website"
            >
              <Globe className="h-5 w-5" />
            </a>
          )}
        </div>
      </div>
    </div>
  );
}

Create the speakers page

Create app/speakers/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { getAPIClient } from '@/lib/api-client';
import { SpeakerCard } from '@/components/speaker-card';
import type { Speaker } from '@/lib/types';

export default function SpeakersPage() {
  const [speakers, setSpeakers] = useState<Speaker[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [selectedTag, setSelectedTag] = useState<string>('all');

  useEffect(() => {
    const loadSpeakers = async () => {
      try {
        const client = getAPIClient();
        const data = await client.getSpeakers();
        setSpeakers(data);
      } catch (err) {
        setError('Failed to load speakers');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    loadSpeakers();
  }, []);

  if (loading) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center">
          <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
          <p className="mt-4 text-muted-foreground">Loading speakers...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center text-destructive">{error}</div>
      </div>
    );
  }

  // Get all unique tags
  const allTags = Array.from(
    new Set(speakers.flatMap((speaker) => speaker.tags))
  ).sort();

  // Filter speakers by selected tag
  const filteredSpeakers =
    selectedTag === 'all'
      ? speakers
      : speakers.filter((speaker) => speaker.tags.includes(selectedTag));

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="mb-12">
        <h1 className="text-4xl font-bold mb-4">Speakers</h1>
        <p className="text-xl text-muted-foreground">
          Meet our {speakers.length} amazing speakers
        </p>
      </div>

      {/* Tag Filter */}
      {allTags.length > 0 && (
        <div className="mb-8 flex flex-wrap gap-2">
          <button
            onClick={() => setSelectedTag('all')}
            className={`px-4 py-2 rounded-lg transition-colors ${
              selectedTag === 'all'
                ? 'bg-primary text-primary-foreground'
                : 'bg-muted hover:bg-muted/80'
            }`}
          >
            All Speakers
          </button>
          {allTags.map((tag) => (
            <button
              key={tag}
              onClick={() => setSelectedTag(tag)}
              className={`px-4 py-2 rounded-lg transition-colors ${
                selectedTag === tag
                  ? 'bg-primary text-primary-foreground'
                  : 'bg-muted hover:bg-muted/80'
              }`}
            >
              {tag}
            </button>
          ))}
        </div>
      )}

      {/* Speakers Grid */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
        {filteredSpeakers.map((speaker) => (
          <SpeakerCard key={speaker.id} speaker={speaker} />
        ))}
      </div>

      {filteredSpeakers.length === 0 && (
        <div className="text-center py-12 text-muted-foreground">
          No speakers found for this tag.
        </div>
      )}
    </div>
  );
}

Client component:This page uses 'use client' because it has interactive filtering. The speakers data is loaded on the client side after the initial page load.

5

Session schedule

Create a schedule page that groups sessions by date and displays them in a timeline format with track color coding.

Create the session card component

Create components/session-card.tsx:

import { format } from 'date-fns';
import { Calendar, Clock, MapPin, User } from 'lucide-react';
import type { Session } from '@/lib/types';

interface SessionCardProps {
  session: Session;
}

export function SessionCard({ session }: SessionCardProps) {
  const startTime = new Date(session.start_time);
  const endTime = new Date(session.end_time);

  return (
    <div
      className="bg-card rounded-lg shadow-md p-6 border-l-4"
      style={{
        borderLeftColor: session.track?.color || '#6366f1',
      }}
    >
      <div className="flex items-start justify-between mb-3">
        <div className="flex-1">
          <h3 className="text-xl font-bold mb-2">{session.title}</h3>
          {session.track && (
            <span
              className="inline-block px-3 py-1 rounded-full text-sm font-medium"
              style={{
                backgroundColor: `${session.track.color}20`,
                color: session.track.color,
              }}
            >
              {session.track.name}
            </span>
          )}
        </div>
      </div>

      {session.description && (
        <p className="text-muted-foreground mb-4">
          {session.description}
        </p>
      )}

      <div className="space-y-2 mb-4">
        <div className="flex items-center gap-2 text-sm text-muted-foreground">
          <Clock className="h-4 w-4" />
          <span>
            {format(startTime, 'h:mm a')} - {format(endTime, 'h:mm a')}
          </span>
        </div>
        {session.room && (
          <div className="flex items-center gap-2 text-sm text-muted-foreground">
            <MapPin className="h-4 w-4" />
            <span>{session.room}</span>
          </div>
        )}
      </div>

      {session.speakers.length > 0 && (
        <div className="border-t pt-4">
          <div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
            <User className="h-4 w-4" />
            <span className="font-medium">Speakers:</span>
          </div>
          <div className="flex flex-wrap gap-2">
            {session.speakers.map((speaker) => (
              <span
                key={speaker.id}
                className="text-sm text-foreground"
              >
                {speaker.name}
              </span>
            ))}
          </div>
        </div>
      )}

      {session.tags.length > 0 && (
        <div className="mt-3 flex flex-wrap gap-2">
          {session.tags.map((tag) => (
            <span
              key={tag}
              className="px-2 py-1 bg-muted text-foreground text-xs rounded"
            >
              {tag}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

Create the schedule page

Create app/schedule/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import { getAPIClient } from '@/lib/api-client';
import { SessionCard } from '@/components/session-card';
import type { Session } from '@/lib/types';

export default function SchedulePage() {
  const [sessions, setSessions] = useState<Session[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [selectedDate, setSelectedDate] = useState<string>('');

  useEffect(() => {
    const loadSessions = async () => {
      try {
        const client = getAPIClient();
        const data = await client.getSessions();
        setSessions(data);

        // Set initial selected date to first session date
        if (data.length > 0 && !selectedDate) {
          const firstDate = format(new Date(data[0].start_time), 'yyyy-MM-dd');
          setSelectedDate(firstDate);
        }
      } catch (err) {
        setError('Failed to load schedule');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    loadSessions();
  }, [selectedDate]);

  if (loading) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center">
          <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
          <p className="mt-4 text-muted-foreground">Loading schedule...</p>
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center text-destructive">{error}</div>
      </div>
    );
  }

  // Group sessions by date
  const sessionsByDate = sessions.reduce((acc, session) => {
    const date = format(new Date(session.start_time), 'yyyy-MM-dd');
    if (!acc[date]) {
      acc[date] = [];
    }
    acc[date].push(session);
    return acc;
  }, {} as Record<string, Session[]>);

  // Sort sessions within each date by start time
  Object.keys(sessionsByDate).forEach((date) => {
    sessionsByDate[date].sort(
      (a, b) =>
        new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
    );
  });

  const dates = Object.keys(sessionsByDate).sort();
  const currentDateSessions = sessionsByDate[selectedDate] || [];

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="mb-12">
        <h1 className="text-4xl font-bold mb-4">Schedule</h1>
        <p className="text-xl text-muted-foreground">
          Browse all {sessions.length} sessions
        </p>
      </div>

      {/* Date Tabs */}
      {dates.length > 0 && (
        <div className="mb-8 flex flex-wrap gap-2">
          {dates.map((date) => {
            const dateObj = new Date(date);
            return (
              <button
                key={date}
                onClick={() => setSelectedDate(date)}
                className={`px-6 py-3 rounded-lg transition-colors ${
                  selectedDate === date
                    ? 'bg-primary text-primary-foreground'
                    : 'bg-muted hover:bg-muted/80'
                }`}
              >
                {format(dateObj, 'EEEE, MMM d')}
              </button>
            );
          })}
        </div>
      )}

      {/* Sessions List */}
      <div className="space-y-6">
        {currentDateSessions.map((session) => (
          <SessionCard key={session.id} session={session} />
        ))}
      </div>

      {currentDateSessions.length === 0 && (
        <div className="text-center py-12 text-muted-foreground">
          No sessions scheduled for this date.
        </div>
      )}
    </div>
  );
}
6

Ticket purchase

Create a tickets page where users can view ticket types, add them to a cart, fill out a registration form, and complete their order.

Understanding registration architecture

Before building the registration form, it's important to understand how EventNerds handles attendee data with a privacy-first architecture:

Data storage model

┌─────────────┐
│   Attendee  │ (Global profile: email, name)
└──────┬──────┘
       │
       │ has many
       │
┌──────▼──────┐
│    Order    │ (Event-specific: ticket, payment)
└──────┬──────┘
       │
       │ has many
       │
┌──────▼──────────────┐
│ Registration        │ (Event-specific responses)
│ Responses           │
└─────────────────────┘

Global Attendee Profile:

  • Shared across all events
  • Contains basic information only (email, name)
  • Automatically merged by email address

Per-Order Data:

  • Registration question responses
  • Payment information
  • Event-specific metadata
  • Privacy-sensitive data

This architecture ensures:

  • Privacy: Sensitive responses are never shared across events
  • Flexibility: Same attendee can have different responses for different events
  • Compliance: Easy to delete event-specific data without affecting the global profile

Custom registration questions

EventNerds allows you to add custom registration questions to collect additional information from attendees. These questions are configured in your event dashboard and fetched via the API at registration time.

Note: For this tutorial, we'll build a simplified registration form with just basic attendee information. To add custom registration questions to your implementation, see the Registration Questions API documentation for detailed integration steps.

Create the ticket card component

Create components/ticket-card.tsx:

import { Check, Minus, Plus } from 'lucide-react';
import type { Ticket } from '@/lib/types';

interface TicketCardProps {
  ticket: Ticket;
  quantity: number;
  onQuantityChange: (quantity: number) => void;
}

export function TicketCard({ ticket, quantity, onQuantityChange }: TicketCardProps) {
  const available = ticket.quantity_total - ticket.quantity_sold;
  const isAvailable = available > 0 && ticket.is_active;

  const handleIncrement = () => {
    if (quantity < available) {
      onQuantityChange(quantity + 1);
    }
  };

  const handleDecrement = () => {
    if (quantity > 0) {
      onQuantityChange(quantity - 1);
    }
  };

  return (
    <div className={`bg-card rounded-lg shadow-md overflow-hidden ${
      !isAvailable ? 'opacity-60' : ''
    }`}>
      <div className="p-6">
        <div className="flex items-start justify-between mb-4">
          <div>
            <h3 className="text-2xl font-bold mb-2">{ticket.name}</h3>
            <div className="text-3xl font-bold text-primary">
              ${ticket.price.toFixed(2)}
              <span className="text-base font-normal text-muted-foreground">
                {' '}
                {ticket.currency}
              </span>
            </div>
          </div>
          {!isAvailable && (
            <span className="px-3 py-1 bg-destructive/10 text-destructive text-sm font-medium rounded-full">
              Sold Out
            </span>
          )}
        </div>

        {ticket.description && (
          <p className="text-muted-foreground mb-6">
            {ticket.description}
          </p>
        )}

        {ticket.features && ticket.features.length > 0 && (
          <ul className="space-y-2 mb-6">
            {ticket.features.map((feature, index) => (
              <li key={index} className="flex items-start gap-2">
                <Check className="h-5 w-5 text-success shrink-0 mt-0.5" />
                <span className="text-sm">{feature}</span>
              </li>
            ))}
          </ul>
        )}

        <div className="text-sm text-muted-foreground mb-4">
          {available} of {ticket.quantity_total} available
        </div>

        {isAvailable && (
          <div className="flex items-center gap-4">
            <div className="flex items-center gap-2 border rounded-lg">
              <button
                onClick={handleDecrement}
                disabled={quantity === 0}
                className="p-2 hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
                aria-label="Decrease quantity"
              >
                <Minus className="h-4 w-4" />
              </button>
              <span className="w-12 text-center font-medium">{quantity}</span>
              <button
                onClick={handleIncrement}
                disabled={quantity >= available}
                className="p-2 hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
                aria-label="Increase quantity"
              >
                <Plus className="h-4 w-4" />
              </button>
            </div>
            {quantity > 0 && (
              <div className="text-lg font-semibold">
                Total: ${(ticket.price * quantity).toFixed(2)}
              </div>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

Create the tickets page with checkout

Create app/tickets/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { getAPIClient } from '@/lib/api-client';
import { TicketCard } from '@/components/ticket-card';
import type { Ticket } from '@/lib/types';

export default function TicketsPage() {
  const [tickets, setTickets] = useState<Ticket[]>([]);
  const [quantities, setQuantities] = useState<Record<string, number>>({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);
  const [orderComplete, setOrderComplete] = useState(false);

  // Form state
  const [email, setEmail] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  useEffect(() => {
    const loadTickets = async () => {
      try {
        const client = getAPIClient();
        const data = await client.getTickets();
        setTickets(data);

        // Initialize quantities
        const initialQuantities: Record<string, number> = {};
        data.forEach((ticket) => {
          initialQuantities[ticket.id] = 0;
        });
        setQuantities(initialQuantities);
      } catch (err) {
        setError('Failed to load tickets');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    loadTickets();
  }, []);

  const handleQuantityChange = (ticketId: string, quantity: number) => {
    setQuantities((prev) => ({
      ...prev,
      [ticketId]: quantity,
    }));
  };

  const calculateTotal = () => {
    return tickets.reduce((total, ticket) => {
      return total + ticket.price * (quantities[ticket.id] || 0);
    }, 0);
  };

  const hasItemsInCart = Object.values(quantities).some((q) => q > 0);
  const total = calculateTotal();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!hasItemsInCart) {
      setError('Please select at least one ticket');
      return;
    }

    setSubmitting(true);
    setError(null);

    try {
      const client = getAPIClient();

      // Build tickets array
      const ticketItems = Object.entries(quantities)
        .filter(([_, qty]) => qty > 0)
        .map(([ticketId, quantity]) => ({
          ticket_id: ticketId,
          quantity,
        }));

      await client.createOrder({
        email,
        first_name: firstName,
        last_name: lastName,
        tickets: ticketItems,
      });

      setOrderComplete(true);
    } catch (err) {
      setError('Failed to create order. Please try again.');
      console.error(err);
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="text-center">
          <div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
          <p className="mt-4 text-muted-foreground">Loading tickets...</p>
        </div>
      </div>
    );
  }

  if (orderComplete) {
    return (
      <div className="container mx-auto px-4 py-16">
        <div className="max-w-2xl mx-auto text-center">
          <div className="mb-6 text-success">
            <svg
              className="w-24 h-24 mx-auto"
              fill="none"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
            </svg>
          </div>
          <h1 className="text-3xl font-bold mb-4">Registration complete!</h1>
          <p className="text-lg text-muted-foreground mb-8">
            Your order has been submitted successfully. Check your email at{' '}
            <strong>{email}</strong> for confirmation and next steps.
          </p>
          <a
            href="/portal"
            className="inline-block px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
          >
            Go to Attendee Portal
          </a>
        </div>
      </div>
    );
  }

  return (
    <div className="container mx-auto px-4 py-16">
      <div className="mb-12">
        <h1 className="text-4xl font-bold mb-4">Tickets</h1>
        <p className="text-xl text-muted-foreground">
          Choose your ticket type and register for TechConf 2025
        </p>
      </div>

      {error && (
        <div className="mb-6 p-4 bg-destructive/10 border border-destructive text-destructive rounded-lg">
          {error}
        </div>
      )}

      <div className="grid lg:grid-cols-3 gap-8">
        <div className="lg:col-span-2 space-y-6">
          {tickets.map((ticket) => (
            <TicketCard
              key={ticket.id}
              ticket={ticket}
              quantity={quantities[ticket.id] || 0}
              onQuantityChange={(q) => handleQuantityChange(ticket.id, q)}
            />
          ))}
        </div>

        {/* Checkout Form */}
        <div className="lg:col-span-1">
          <div className="bg-card rounded-lg shadow-md p-6 sticky top-4">
            <h2 className="text-2xl font-bold mb-6">Checkout</h2>

            <div className="mb-6">
              <div className="text-sm text-muted-foreground mb-2">
                Total Amount
              </div>
              <div className="text-3xl font-bold">${total.toFixed(2)}</div>
            </div>

            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label
                  htmlFor="email"
                  className="block text-sm font-medium mb-1"
                >
                  Email *
                </label>
                <input
                  type="email"
                  id="email"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  required
                  className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
                />
              </div>

              <div>
                <label
                  htmlFor="firstName"
                  className="block text-sm font-medium mb-1"
                >
                  First Name *
                </label>
                <input
                  type="text"
                  id="firstName"
                  value={firstName}
                  onChange={(e) => setFirstName(e.target.value)}
                  required
                  className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
                />
              </div>

              <div>
                <label
                  htmlFor="lastName"
                  className="block text-sm font-medium mb-1"
                >
                  Last Name *
                </label>
                <input
                  type="text"
                  id="lastName"
                  value={lastName}
                  onChange={(e) => setLastName(e.target.value)}
                  required
                  className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
                />
              </div>

              <button
                type="submit"
                disabled={!hasItemsInCart || submitting}
                className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-lg font-semibold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
              >
                {submitting ? 'Processing...' : 'Complete Registration'}
              </button>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
}

Privacy and compliance

EventNerds is designed with privacy and GDPR compliance in mind. Understanding how data is stored and processed is important for building compliant applications.

Data Storage Architecture
  • Attendee profiles: Global, minimal information shared across events
  • Registration responses: Per-order, event-specific data that stays isolated
  • Sensitive data: Clearly marked with is_sensitive flag, can be excluded from exports
GDPR Compliance

EventNerds is GDPR-compliant out of the box with built-in features for:

  • Data export functionality for attendees
  • Right to deletion (data can be removed per event)
  • Clear data boundaries between events
  • Consent tracking for sensitive questions
Best practices
  • 1. Inform users about what data you're collecting and why
  • 2. Make sensitive questions optional to respect attendee privacy
  • 3. Display privacy policy link prominently in your registration form
  • 4. Provide data export option for attendees to download their information
  • 5. Only collect necessary data - avoid asking for information you don't need

Privacy by design: The per-order data storage model means that sensitive registration responses are never shared across events, giving attendees control over their data for each event they register for independently.

7

Attendee portal

Create an attendee portal where users can log in with a magic link, view their tickets, and access their QR codes.

Create the portal page

Create app/portal/page.tsx:

'use client';

import { useState } from 'react';
import { Mail, Check } from 'lucide-react';

export default function PortalPage() {
  const [email, setEmail] = useState('');
  const [submitted, setSubmitted] = useState(false);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);

    // Simulate API call to send magic link
    await new Promise((resolve) => setTimeout(resolve, 1500));

    setSubmitted(true);
    setLoading(false);
  };

  if (submitted) {
    return (
      <div className="min-h-screen flex items-center justify-center px-4">
        <div className="max-w-md w-full text-center">
          <div className="mb-6 text-success">
            <Check className="w-24 h-24 mx-auto" />
          </div>
          <h1 className="text-3xl font-bold mb-4">Check your email</h1>
          <p className="text-lg text-muted-foreground mb-8">
            We sent a magic link to <strong>{email}</strong>. Click the link in
            the email to access your tickets and account.
          </p>
          <button
            onClick={() => {
              setSubmitted(false);
              setEmail('');
            }}
            className="text-primary hover:underline"
          >
            Use a different email
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="max-w-md w-full">
        <div className="text-center mb-8">
          <Mail className="w-16 h-16 mx-auto mb-4 text-primary" />
          <h1 className="text-3xl font-bold mb-2">Attendee portal</h1>
          <p className="text-muted-foreground">
            Enter your email to receive a magic link to access your tickets
          </p>
        </div>

        <div className="bg-card rounded-lg shadow-md p-8">
          <form onSubmit={handleSubmit} className="space-y-6">
            <div>
              <label
                htmlFor="portal-email"
                className="block text-sm font-medium mb-2"
              >
                Email Address
              </label>
              <input
                type="email"
                id="portal-email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                required
                placeholder="you@example.com"
                className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
              />
            </div>

            <button
              type="submit"
              disabled={loading}
              className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-lg font-semibold hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
            >
              {loading ? 'Sending...' : 'Send Magic Link'}
            </button>
          </form>

          <div className="mt-6 pt-6 border-t text-center text-sm text-muted-foreground">
            <p>The magic link will expire in 15 minutes</p>
          </div>
        </div>

        <div className="mt-8 p-4 bg-info/10 border border-info rounded-lg">
          <p className="text-sm text-info">
            <strong>Note:</strong> In a production environment, this would
            integrate with the EventNerds Attendee Portal API to send real magic
            links and display actual ticket data.
          </p>
        </div>
      </div>
    </div>
  );
}

Simplified example: This is a basic implementation showing the magic link flow. In production, you would integrate with the EventNerds Attendee Portal API endpoints to send actual magic links and display ticket QR codes.

8

Deployment

Now that your event website is complete, let's deploy it to Vercel so it's accessible to the world.

Prepare for deployment

Before deploying, make sure your code is ready:

# Run the linter to check for issues
npm run lint

# Build the project to catch any errors
npm run build

# Test the production build locally
npm run start

Deploy to Vercel

Follow these steps to deploy your site:

  1. Install Vercel CLI:
    npm install -g vercel
  2. Deploy from your project directory:
    # Login to Vercel
    vercel login
    
    # Deploy
    vercel
  3. Follow the prompts:
    • Link to existing project or create new
    • Confirm project settings
    • Wait for deployment to complete

Set environment variables in production

Add your environment variables to Vercel:

  1. Go to your project in the Vercel dashboard
  2. Navigate to Settings → Environment Variables
  3. Add the following variables:
    • NEXT_PUBLIC_EVENTNERDS_API_KEY
    • NEXT_PUBLIC_EVENT_ID
    • NEXT_PUBLIC_API_BASE_URL
  4. Redeploy the project for changes to take effect

Custom domain setup

To use a custom domain:

  1. Go to your project in Vercel dashboard
  2. Navigate to Settings → Domains
  3. Click "Add Domain"
  4. Enter your domain name
  5. Follow the DNS configuration instructions
  6. Wait for DNS propagation (can take up to 48 hours)

Testing checklist

After deployment, verify everything works correctly:

Core functionality

  • Homepage loads with correct event information and statistics
  • Speakers page displays all speakers with images and social links
  • Schedule page shows sessions grouped by date with track colors
  • Tickets page displays available ticket types with accurate pricing
  • All navigation links work correctly across all pages

Registration flow

  • Ticket quantity selection works and calculates total correctly
  • Required field validation works on registration form
  • Email format validation displays appropriate error messages
  • Form submission creates order and shows confirmation
  • Error messages are user-friendly and actionable
  • Confirmation page displays order details correctly

Attendee portal

  • Portal page accepts email and shows success message
  • Magic link email is sent (in production)
  • QR codes are generated for completed orders (in production)

Responsive design

  • Mobile navigation menu opens and closes correctly
  • All pages are readable and functional on mobile devices
  • Forms are easy to fill out on mobile screens
  • Images and content scale appropriately on different screen sizes

Performance & UX

  • Page load times are reasonable (under 3 seconds)
  • Loading states display while fetching data
  • Error states provide helpful guidance to users
  • Browser console shows no errors or warnings

Congratulations!

You have successfully built and deployed a complete event website using Next.js and the EventNerds API. Your website now includes:

  • A beautiful homepage showcasing your event
  • A speakers directory with filtering capabilities
  • A full schedule view organized by date and track
  • A ticket purchase flow with cart and checkout
  • An attendee portal for ticket access
  • Responsive design that works on all devices

Next steps

Here are some ideas to enhance your event website further:

  • Add a venue map and directions page
  • Implement a live Q&A feature for sessions
  • Add social media integration for sharing
  • Create a sponsors showcase page
  • Implement session favorites and personal schedule building
  • Add custom branding and theming
  • Implement server-side rendering for better SEO
  • Add analytics tracking for user behavior

Note: EventNerds automatically handles payment processing, real-time attendance tracking, and can send notifications through your MarTech integrations (Customer.io, Mailchimp) or Zapier connections.

Additional resources

Full example code

Download the complete working example with all files and configuration.

Download Example →
API Reference

Explore the complete API documentation for all endpoints.

View API Docs →

Need help?

If you run into any issues or have questions while building your event website, our team is here to help.

Ready to get started?

Create your free EventNerds account and start building amazing events today. No credit card required.

Built by EventNerdsStrategic Nerds

The API-first event engine for developers who mean business