Building a Live Concert Enhancement Platform for TikTok with Next.js

Sirus SalariSirus Salari
26 min read

Introduction

I recently participated in the TikTok TechJam hackathon and developed "TikTok Concerts," a platform enhancing live concert experiences on TikTok Live. The project features event scheduling, a global events map, and user interfaces for artists and fans, built using Next.js, TypeScript, React, and Tailwind CSS. If you find the project interesting, please explore the platform and consider voting for it in the hackathon!

I just submitted my project for the TikTok TechJam hackathon!

For those who don't know, TikTok just held its first hackathon. TechJam is a hackathon open to college students, with multiple tracks to choose from. I chose the music discovery track. The goal was to create a project that enhances artist exposure on TikTok, promotes diverse content discovery, boosts the viral potential of music on TikTok, and increases engagement and interaction between artists and fans.

TikTok is a social media platform known for its short videos, and it has become a major player in the music industry. Many artists have gained popularity through viral TikTok videos, making it an important platform for music discovery and promotion.

Overview of TikTok Concerts

My project, titled TikTok Concerts, is designed to enhance the experience of watching live concerts on TikTok Live. It offers several features to help artists and users connect more easily.

The inspiration for this project came from an event called TikTok In The Mix. This event was the first global live concert and set the record for the biggest live event ever on TikTok. Since it was so popular, I thought it would be a good idea to create features that enhance the live concert experience for both artists and fans. These features will encourage artists to share their performances on TikTok Live and invite fans to watch through the TikTok app. It will also help smaller artists reach a wider audience.

What it Does

My project supports the following main features:

  • Event Scheduling: Artists can schedule their live performances and make them publicly available.

  • Global Events Map: A dynamic map showing all scheduled events worldwide.

Fans can go to /users to see upcoming events on a dynamic map and view details like the date and description of each event. Artists can go to /artists to see events they have scheduled, delete events, update events, and add new events. The website is also designed to be responsive on smaller screens.

For example, an artist can schedule a live performance, and fans can easily find and join the event through the global events map. This makes it easier for artists to promote their events and for fans to discover new music.

How I Built it

This project is built using the following technologies:

I used Next.js with React to create the pages, app layout, client components, and server components. I used TypeScript for type safety and to create interfaces. I used MongoDB to design the database event schema and model.

Next.js was chosen for its powerful features like server-side rendering and static site generation, which help improve performance and SEO. TypeScript ensures type safety and reduces runtime errors, while Tailwind CSS provides a utility-first approach to design, ensuring responsive and consistent styling.

Background and Technology Overview

Introduction to Next.js

Next.js is a powerful, open-source JavaScript framework built on top of React, created by Vercel (formerly known as ZEIT). It offers a robust set of features for building modern web applications, including server-side rendering (SSR), static site generation (SSG), and automatic code splitting. These capabilities make Next.js an excellent choice for developers looking to create high-performance, SEO-friendly web applications.

Key Features of Next.js Relevant to the Project

The "TikTok Concerts" project uses several important features of Next.js to build a dynamic, responsive, and high-performance web application. Here’s a detailed look at how these features are used:

  1. App directory structure and nested routing

    • The app directory provides a more intuitive and nested routing system. For example, routes for artists and users are organized within subdirectories, making the code easier to maintain.
  2. Server actions

    • Data Fetching: Instead of traditional API routes, server actions fetch data directly within components, reducing the need for client-side fetching and improving performance.

    • Data Mutations: Actions like creating, updating, or deleting events are handled server-side within the component's scope. This streamlines the logic and enhances security.

  3. Server side rendering

    • In Next.js you can create server components to render UI on the server

    • Server components can improve performance by reducing the amount of client-side rendering

    • Server-side rendering helps with search engine optimization (SEO) because search engines can crawl and index content more effectively when it's rendered on the server.

  4. Next.js router cache

    • Next.js Router Cache improves page navigation by storing the results of data fetching for each page.

    • Faster Navigation: Pages you have visited before load almost instantly when you revisit them, improving the user experience.

    • Efficient Data Management: Reduces unnecessary data fetching, improving overall application performance.

  5. TypeScript Integration

    • TypeScript enhances code quality with type safety and clear data structures

    • Type Safety: Reduces runtime errors and improves development efficiency.

    • Interfaces and Types: Ensures consistency across the codebase.

  6. Tailwind Integration

    • Tailwind CSS provides a utility-first approach to design, ensuring responsive and consistent styling across components

    • Responsive Design: The design adapts to different devices, ensuring a consistent and user-friendly experience.

    • Consistent Styling: Simplifies the styling process using reusable utility classes.

User Experience Design

UI/UX Principles for Engagement

  1. Responsive Design: Ensures the website is accessible and user-friendly on different devices, enhancing the overall user experience.

  2. User and Artist Pages: Separate pages for users and artists to manage and view events, offering a customized experience for each type of user.

  3. Interactive Features: Includes elements like event details and descriptions to engage users and provide them with relevant information.

Screenshot of a website titled "TikTok Concerts," showing upcoming events on a map of the United States, focusing on California with event locations marked in San Francisco, Los Angeles, and San Diego. Below the map is a description of a "Jazz Night" event scheduled on 2024-08-15. The website has navigation links for Home, About, Users, Artists, and GitHub Repo at the top.

Development Process

Feel free to follow along with my code by visiting the GitHub repo.

Setting Up the Development Environment with Next.js

I started by setting up a new Next.js project with TypeScript support:

npx create-next-app@latest

From there, I answered the prompts in the terminal as follows:

  1. What is your project named? tiktok-concerts

  2. Would you like to use TypeScript? Yes

  3. Would you like to use ESLint? Yes

  4. Would you like to use Tailwind CSS? Yes

  5. Would you like to use src/ directory? Yes

  6. Would you like to use App Router? (recommended) Yes

  7. Would you like to customize the default import alias (@/*)? No

After answering these prompts, your project will be configured as specified, and all the dependencies will be automatically installed.

Building the page routes

Within my src directory, I created a new folder called app to store all the page routes. The app folder contains the following items:

  • A CSS file that imports Tailwind, where you can also define custom CSS rules.
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 214, 219, 220;
  --background-end-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
  :root {
    --foreground-rgb: 255, 255, 255;
    --background-start-rgb: 0, 0, 0;
    --background-end-rgb: 0, 0, 0;
  }
}

body {
  color: rgb(var(--foreground-rgb));
  background: linear-gradient(
      to bottom,
      transparent,
      rgb(var(--background-end-rgb))
    )
    rgb(var(--background-start-rgb));
}

@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
}
  • A home page

      "use client";
      import React from 'react';
    
      /**
       * Home page component
       * 
       * @returns The home page component.
       */
      export default function Home() {
        return (
          <main className="flex flex-col items-center gap-10 justify-between p-4 sm:p-8 md:p-16 lg:p-24">
            <h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold my-4">Welcome to TikTok Concerts!</h1>
            <p className="text-sm sm:text-base md:text-lg lg:text-xl">Discover live streamed concerts and performances near you.</p>
          </main>
        );
      }
    
  • An app layout that imports the CSS file, defines metadata for the homepage, and contains the content inherited throughout the rest of the app.

import type { Metadata } from "next";
import "./globals.css";
import React from "react";
import ToggleMenu from "@/components/ToggleMenu";

// Metadata for the layout.
export const metadata: Metadata = {
  title: "TikTok Concerts",
  description: "Watch your favorite artists perform live on TikTok."
};

/**
 * Root layout component.
 * 
 * @param children - The content to be rendered within the layout.
 * 
 * @returns The layout component.
 */
export default async function RootLayout({
  children,
  }: Readonly<{
    children: React.ReactNode;
  }>) {
    return (
      <html lang="en">
      <body>
        <div className="flex flex-col h-screen justify-between">
          <ToggleMenu />

          <main className="mb-auto p-4 h-fit">
            {children}
          </main>

          <footer className="h-10 text-center">
            <p>
            Disclaimer: This project, TikTok Concerts, is not officially affiliated with, endorsed by, or sponsored by TikTok. All trademarks and copyrights are the property of their respective owners.
            </p>
            <p>&copy; 2024 Sirus Salari. All rights reserved</p>
          </footer>
        </div>
      </body>
      </html>
    );
}
  • A separate folder for each page, including /about, /artists, /update, and /users

    • Within each folder, there is a page.tsx file where you write out the HTML for the page, and import components

    • Additionally, within each folder, there is a layout.tsx file where you define metadata for that page and inherit the root app layout.

Creating the components

Within the src directory, there's a folder called components. Inside the components directory, I created the following components:

  • EventCard - A server component that effectively fetches, displays, and provides options to delete or update events in a user-friendly manner

      import React from 'react';
      import { deleteEvent, getEvents } from "@/lib/action";
    
      /**
       * Get all events.
       * 
       * @returns All events.
       */
      export default async function GetEvents() {
          try {
              const events = await getEvents();
              if (events.length === 0) {
                  return <h1>No Events</h1>;
              } else {
                  return (
                      <div className='grid grid-rows-1 md:grid-rows-2 lg:grid-rows-3 gap-5'>
                          {events.map((event: any) => (
                              <div key={event._id} className='p-5 border rounded shadow-md'>
                                  <h3 className='text-center text-lg md:text-xl lg:text-2xl font-bold'>{event.title as string}</h3>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Description: </b>{event.description as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Date: </b>{event.date as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Address: </b>{event.address as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.city as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.state as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.zip as string}</p>
                                  <form action={deleteEvent} className='m-4'>
                                      <input hidden type="text" name="id" defaultValue={event._id.toString()}/>
                                      <button type="submit" className='hover:bg-gradient-to-r from-splash to-razzmatazz hover:text-black inline-flex justify-center py-2 px-4 sm:px-6 md:px-8 lg:px-10 border border-transparent shadow-sm text-sm sm:text-base md:text-lg lg:text-xl font-medium rounded-md text-white'>Delete</button>
                                  </form>
                                  <form action="/update" className='m-4'>
                                      <input hidden type="text" name="id" defaultValue={event._id.toString()}/>
                                      <button className='hover:bg-gradient-to-r from-splash to-razzmatazz hover:text-black inline-flex justify-center py-2 px-4 sm:px-6 md:px-8 lg:px-10 border border-transparent shadow-sm text-sm sm:text-base md:text-lg lg:text-xl font-medium rounded-md text-white'>Update</button>
                                  </form>
                              </div>
                          ))}
                      </div>
                  );
              }
          } catch (error) {
              console.log(error);
          }
      };
    
    • This component is used in the /artists page, and imports the server actions for deleting and fetching events

    • First, a call is made to the getEvents server action. If no data is returned, the component will display HTML stating that there are no events.

    • If data is returned, each event in the array is mapped to a div with the key attribute set to the MongoDB ObjectID value of the event.

    • Within the div, there are child elements to display the event's details, such as the title, description, date, address, city, state, and zip code.

    • Additionally, each div contains two forms: a delete form that submits to the deleteEvent server action with the event's ObjectID as a hidden input, and an update form that redirects to /update with the event's ObjectID as a hidden input.

    • Events are displayed in a responsive grid layout that adjusts the number of rows based on the screen size.

  • EventForm - A client component that provides a user-friendly interface for creating and submitting new events, with responsive design and consistent styling

      "use client";
      import { createEvent } from "@/lib/action";
      import React, { useRef } from "react";
    
      /**
       * Event form component.
       * 
       * @returns The event form component.
       */
      export default function EventForm() {
          // Create a reference to the form element
          const ref = useRef<HTMLFormElement>(null);
    
          const inputStyle = {
              color: 'black',
              backgroundColor: 'white',
              border: '1px solid black',
              padding: '8px',
              display: 'block',
              width: '100%',
              borderRadius: '5px',
          };
    
          return (
              <div className='container mx-auto px-4 sm:px-8 md:px-16 lg:px-24'>
                  <h2 className='text-center m-4 text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold'>Add Event</h2>
                  <form className='space-y-4' autoComplete="off" ref={ref} action={async (FormData) => {
                      ref.current?.reset();
                      await createEvent(FormData);
                  }}>
                      <div>
                          <label htmlFor="title" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Title</label>
                          <input type="text" name="title" id='title' style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="description" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Description</label>
                          <textarea id='description' name="description" style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="date" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Date</label>
                          <input type="date" name="date" id='date' style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="address" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Address</label>
                          <input type="text" name="address" id='address' style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="city" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>City</label>
                          <input type="text" name="city" id='city' style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="state" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>State</label>
                          <input type="text" name="state" id='state' style={inputStyle} required />
                      </div>
                      <div>
                          <label htmlFor="zip" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Zip</label>
                          <input type="text" name="zip" id='zip' style={inputStyle} required />
                      </div>
                      <button type='submit' className='hover:bg-gradient-to-r from-splash to-razzmatazz hover:text-black inline-flex justify-center py-2 px-4 sm:px-6 md:px-8 lg:px-10 border border-transparent shadow-sm text-sm sm:text-base md:text-lg lg:text-xl font-medium rounded-md text-white'>Submit</button>            
                  </form>
              </div>
          );
      };
    
    • This component is used in the /artists page, and imports the server action for creating events, as well as the useRef React Hook

    • First, I created a reference to the form element with useRef, and this reference will be used later on to reset the form fields after submission

    • The component returns a form where the action attribute is set to an async function that first resets the form, then calls createEvent with the form data

    • The form includes several input fields for title, description, address, city, state, and zip.

  • EventList - A server component that effectively fetches and displays events in a user-friendly manner, ensuring a responsive and organized presentation of event details

      import React from 'react';
      import { getEvents } from "@/lib/action";
    
      /**
       * Get all events.
       * 
       * @returns All events.
       */
      export default async function GetEvents() {
          try {
              // Get all events
              const events = await getEvents();
              if (events.length === 0) {
                  return <h1>No Events</h1>;
              } else {
                  return (
                      <div className='grid grid-rows-1 md:grid-rows-2 lg:grid-rows-3 gap-5'>
                          {events.map((event: any) => (
                              <div key={event._id} className='p-5 border rounded shadow-md'>
                                  <h3 className='text-lg text-center md:text-xl lg:text-2xl font-bold'>{event.title as string}</h3>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Description:</b> {event.description as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Date:</b> {event.date as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'><b>Address:</b> {event.address as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.city as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.state as string}</p>
                                  <p className='text-sm md:text-base lg:text-lg'>{event.zip as string}</p>
                              </div>
                          ))}
                      </div>
                  );
              }
          } catch (error) {
              console.log(error);
          }
      };
    
    • This component is used in the /users page, and imports the server action for fetching events

    • First, a call is made to getEvents to fetch the list of events. If the array is empty, it returns HTML stating that there are no events

    • If there are events in the array, each event is mapped to a div with the key attribute set to the event's ObjectID, with child elements to display the title, description, date, address, city, state, and zip.

    • The events are displayed in a responsive grid layout that adjusts the number of rows based on the screen size.

  • Map - A client component that provides a dynamic and interactive map that displays event locations with markers, enhancing the user experience with geocoding and responsive design

      'use client'
      import React, { useCallback, useState } from 'react';
      import { GoogleMap, Marker, useJsApiLoader} from '@react-google-maps/api';
      import { Event } from '@/types/event';
    
      const mapContainerStyle = {
          width: '100%',
          height: '100%'
      };
    
      // Map component props
      interface MapProps {
          events: Event[];
      }
    
      const nightModeStyles = [
          { elementType: "geometry", stylers: [{ color: "#242f3e" }] },
          { elementType: "labels.text.stroke", stylers: [{ color: "#242f3e" }] },
          { elementType: "labels.text.fill", stylers: [{ color: "#746855" }] },
          {
            featureType: "administrative.locality",
            elementType: "labels.text.fill",
            stylers: [{ color: "#d59563" }],
          },
          {
            featureType: "poi",
            elementType: "labels.text.fill",
            stylers: [{ color: "#d59563" }],
          },
          {
            featureType: "poi.park",
            elementType: "geometry",
            stylers: [{ color: "#263c3f" }],
          },
          {
            featureType: "poi.park",
            elementType: "labels.text.fill",
            stylers: [{ color: "#6b9a76" }],
          },
          {
            featureType: "road",
            elementType: "geometry",
            stylers: [{ color: "#38414e" }],
          },
          {
            featureType: "road",
            elementType: "geometry.stroke",
            stylers: [{ color: "#212a37" }],
          },
          {
            featureType: "road",
            elementType: "labels.text.fill",
            stylers: [{ color: "#9ca5b3" }],
          },
          {
            featureType: "road.highway",
            elementType: "geometry",
            stylers: [{ color: "#746855" }],
          },
          {
            featureType: "road.highway",
            elementType: "geometry.stroke",
            stylers: [{ color: "#1f2835" }],
          },
          {
            featureType: "road.highway",
            elementType: "labels.text.fill",
            stylers: [{ color: "#f3d19c" }],
          },
          {
            featureType: "transit",
            elementType: "geometry",
            stylers: [{ color: "#2f3948" }],
          },
          {
            featureType: "transit.station",
            elementType: "labels.text.fill",
            stylers: [{ color: "#d59563" }],
          },
          {
            featureType: "water",
            elementType: "geometry",
            stylers: [{ color: "#17263c" }],
          },
          {
            featureType: "water",
            elementType: "labels.text.fill",
            stylers: [{ color: "#515c6d" }],
          },
          {
            featureType: "water",
            elementType: "labels.text.stroke",
            stylers: [{ color: "#17263c" }],
          },
      ];
    
      /**
       * Map component.
       * 
       * @param {MapProps} props Map component props.
       * @returns {JSX.Element} Map component.
       */
      export default function Map({ events }: MapProps) {
          let geocoder: google.maps.Geocoder;
          const [geocodedEvents, setGeocodedEvents] = useState(events);
    
          /**
           * Geocode the events.
           * 
           * @returns {void}
           * @throws {Error} Geocoding failed for address.
           * @returns {Promise<Event[]>} The geocoded events.
           */
          const geocode = useCallback(() => {
              geocoder = new google.maps.Geocoder();
    
              // Geocode the events
              const geocodePromises: Promise<Event>[] = events.map(event => new Promise((resolve, reject) => {
                  const address = `${event.address}, ${event.city}, ${event.state} ${event.zip}`;
                  geocoder.geocode({ address: address }, (results, status) => {
                      if (status === 'OK' && results !== null) {
                          event.latitude = results[0].geometry.location.lat().toString();
                          event.longitude = results[0].geometry.location.lng().toString();
                          resolve(event);
                      } else {
                          reject(new Error(`Geocoding failed for address: ${event.address}`));
                      }
                  });
              }));
              // Set the geocoded events
              Promise.all(geocodePromises)
                  .then(geocodedEvents => {
                      setGeocodedEvents(geocodedEvents);
                  })
                  .catch(error => console.error(error));
          }, [events]);
    
          // Load the Google Maps API
          const { isLoaded } = useJsApiLoader({
              id: 'google-map-script',
              googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!,
          })
    
          return (
              <div className='w-full h-64 sm:h-96 md:h-128 lg:h-256'>
                  {isLoaded && <GoogleMap options={{ styles: nightModeStyles }} mapContainerStyle={mapContainerStyle} center={
                      {
                          lat: parseFloat(geocodedEvents[0].latitude) || 0,
                          lng: parseFloat(geocodedEvents[0].longitude) || 0
                      }
                  } zoom={4} onLoad={geocode}>
                      {geocodedEvents.map(event => (
                          <Marker key={event._id} position={{ lat: parseFloat(event.latitude), lng: parseFloat(event.longitude) }} />
                      ))}
                  </GoogleMap>}
              </div>
          );
      }
    
    • This component is used in the /users page, and imports the event interface, the useCallback and useState React Hooks, as well as the GoogleMap, Marker, and useJsApiLoader components from the react-google-maps/api library

    • First, I defined the map component properties which is an array of events where each event is of type Event (as defined and imported by the event interface)

    • Next, I set up a function to geocode the addresses (the Google Maps API requires longitude and latitude data to place markers on a mp)

    • The geocode function uses the useCallback hook, and maps over the events array and creates a promise for each event to geocode its address

    • If geocoding is successful, it updates the event's latitude and longitude

    • If geocoding fails, it rejects the promise with an error

    • Promise.all is used to wait for all geocoding promises to resolve, and the geocodedEvents state is updated with the results

    • The component then returns a div with responsive height classes

    • If the Google Maps API is loaded, it renders a GoogleMap component with the following properties

      • options: sets the map styles to nightModeStyles

      • mapContainerStyle: sets the container style to mapContainerStyle

      • center: sets the initial center of the map to the latitude and longitude of the first geocoded event

      • zoom: sets the initial zoom level to 4

      • onLoad: calls the geocode function when the map loads

    • Inside the GoogleMap component, it maps over the geocodedEvents array and renders a marker for each event at its geocoded position

  • ToggleMenu - A client component that provides a responsive and interactive header, enhancing the user experience with a mobile-friendly navigation menu

      'use client';
      import { useState, useEffect } from 'react';
      import { usePathname } from 'next/navigation';
      import { FaBars, FaTimes } from 'react-icons/fa';
      import Link from 'next/link';
    
      /**
       * Header component.
       * 
       * @returns {JSX.Element} Header component.
       */
      export default function Header () {
          const [isOpen, setIsOpen] = useState(false);
          const pathname = usePathname();
    
          const toggleMenu = () => {
              setIsOpen(!isOpen);
          };
    
          // Close the menu when the path changes
          useEffect(() => {
              setIsOpen(false);
          }, [pathname]);
    
          return (
              <header className="bg-gradient-to-r from-splash to-razzmatazz text-black p-4">
                  <div className="container mx-auto flex justify-between items-center">
                      <h1 className="text-2xl font-bold">TikTok Concerts</h1>
                      <div className="text-2xl lg:hidden" onClick={toggleMenu}>
                          {isOpen ? <FaTimes /> : <FaBars />}
                      </div>
                      <nav className={`hidden lg:flex space-x-4`}>
                          <Link href="/" className="hover:underline font-bold p-2">Home</Link>
                          <Link href="/about" className="hover:underline font-bold p-2">About</Link>
                          <Link href="/users" className="hover:underline font-bold p-2">Users</Link>
                          <Link href="/artists" className="hover:underline font-bold p-2">Artists</Link>
                          <Link target="_blank" href="https://github.com/sirus-the-beaver/tiktok-concerts" className="hover:underline font-bold p-2">GitHub Repo</Link>
                      </nav>
                  </div>
                  {isOpen && (
                      <nav className="bg-gray-800 lg:hidden">
                          <ul className="flex flex-col items-center space-y-2 p-4">
                              <li><Link href="/" className="hover:underline font-bold block p-2">Home</Link></li>
                              <li><Link href="/about" className="hover:underline font-bold block p-2">About</Link></li>
                              <li><Link href="/users" className="hover:underline font-bold block p-2">Users</Link></li>
                              <li><Link href="/artists" className="hover:underline font-bold block p-2">Artists</Link></li>
                              <li><Link target="_blank" href="https://github.com/sirus-the-beaver/tiktok-concerts" className="hover:underline font-bold block p-2">GitHub Repo</Link></li>
                          </ul>
                      </nav>
                  )}
              </header>
          );
      };
    
    • This component is used in the root app layout, and imports the useState and useEffect React Hooks, the usePathname Next.js component, the FaBars and FaTimes React icons, and the Link React component

    • First, I set a state variable, isOpen, to false to manage the menu's open/close state

    • Next, I define the toggleMenu function which toggles the isOpen state between true and false

    • The useEffect hook is used to close the menu whenever the path changes (a user clicks a link to go to another page)

    • The component returns a header element with a div for the menu icon that toggles the menu on click, visible only on smaller screens, as well as a nav that is visible only on larger screens

    • If isOpen is true, a secondary nav is rendered for smaller screens

    • The header is responsive, with different navigation layouts for larger and smaller screens

  • UpdateEventForm - A client component that provides a user-friendly interface for updating an existing event, with responsive design and consistent styling

      'use client';
      import { updateEvent } from '@/lib/action';
      import React, { useRef } from 'react';
      import { redirect } from 'next/navigation';
    
      /**
       * Update event form component.
       * 
       * @returns The update event form component.
       */
      export default function UpdateEventForm({eventId}: {eventId: string}) {
          const ref = useRef<HTMLFormElement>(null);
    
          const inputStyle = {
              color: 'black',
              backgroundColor: 'white',
              border: '1px solid black',
              padding: '8px',
              display: 'block',
              width: '100%',
              borderRadius: '5px',
          };
    
          /** 
           * Handle the form submission.
           * 
           * @param FormData - The form data.
           * 
           * @returns The updated event.
          */
          const actionHandler = async (FormData: any) => {
              ref.current?.reset();
              await updateEvent(FormData);
    
              redirect('/artists');
          }
    
          return (
              <div className='container mx-auto px-4 sm:px-8 md:px-16 lg:px-24'>
                  <h2 className='text-center m-4 text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold'>Update Event</h2>
                  <form autoComplete="off" className='space-y-4' ref={ref} action={actionHandler}>
                      <input hidden type="text" name="id" id='id' defaultValue={eventId}/>
                      <div>
                          <label htmlFor="title" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Title</label>
                          <input type="text" name="title" id='title' style={inputStyle}/>
                      </div>
                      <div>
                          <label htmlFor="description" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Description</label>
                          <textarea id='description' name="description" style={inputStyle} />
                      </div>
                      <div>
                          <label htmlFor="date" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Date</label>
                          <input type="date" name="date" id='date' style={inputStyle} />
                      </div>
                      <div>
                          <label htmlFor="address" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Address</label>
                          <input type="text" name="address" id='address' style={inputStyle} />
                      </div>
                      <div>
                          <label htmlFor="city" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>City</label>
                          <input type="text" name="city" id='city' style={inputStyle} />
                      </div>
                      <div>
                          <label htmlFor="state" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>State</label>
                          <input type="text" name="state" id='state' style={inputStyle} />
                      </div>
                      <div>
                          <label htmlFor="zip" className='block text-sm sm:text-base md:text-lg lg:text-xl font-medium text-gray-700'>Zip</label>
                          <input type="text" name="zip" id='zip' style={inputStyle} />
                      </div>
                      <button type='submit' className='hover:bg-gradient-to-r from-splash to-razzmatazz hover:text-black inline-flex justify-center py-2 px-4 sm:px-6 md:px-8 lg:px-10 border border-transparent shadow-sm text-sm sm:text-base md:text-lg lg:text-xl font-medium rounded-md text-white'>Update</button>            </form>
              </div>
          )
      }
    
    • This component is used in the /update page, and imports the server action for updating events, as well as the useRef React Hook, and the redirect function from Next.js

    • First, I defined a reference to the form with useRef, which will be used later on to reset the form after submission

    • Next, I define the actionHandler function which is an async function that handles the form submission. It resets the form fields, calls updateEvent with the form data, and then redirects the user to the /artists page after the event is updated

    • The component returns a form with the action attribute set to the actionHandler function

    • The form includes several input fields including, ID (a hidden input field for the event ID, pre-filled with the eventId prop), title, description, date, address, city, state, and zip

    • The form fields are not marked as required, allowing for partial updates

Defining the server actions

Within my src directory, I created another directory called lib, where I added a file named action.ts that contains the server actions.

'use server';
import Event from "@/models/Event";
import { revalidatePath } from "next/cache";
import { connectToDatabase } from "./db";

/**
 * Get all events.
 * 
 * @returns The events.
 */
export async function getEvents() {
    await connectToDatabase();

    try {
        const data = await Event.find();
        return JSON.parse(JSON.stringify(data));
    } catch (error) {
        console.log(error);
        return [];
    }
}

/**
 * Create a new event.
 * 
 * @param formData - The form data.
 * 
 * @returns The new event.
 */
export async function createEvent(formData: FormData) {
    // Connect to the database
    await connectToDatabase();

    // Get the form data
    const title = formData.get('title') as string;
    const description = formData.get('description') as string;
    const date = formData.get('date') as string;
    const address = formData.get('address') as string;
    const city = formData.get('city') as string;
    const state = formData.get('state') as string;
    const zip = formData.get('zip') as string;

    try {
        // Create a new event
        const newEvent = await Event.create({
            title,
            description,
            date,
            address,
            city,
            state,
            zip
        });

        newEvent.save();

        // Update the events page with the new event
        revalidatePath('/artists');
        revalidatePath('/users');
        return newEvent.toString();
    } catch (error) {
        console.log(error);
        return{message: 'Failed to create a new event'};
    }
};

/**
 * Delete an event.
 * 
 * @param id - The event id.
 * 
 * @returns The deleted event.
 */
export async function deleteEvent(id: FormData) {
    await connectToDatabase();
    const eventId = id.get('id') as string;

    try {
        await Event.deleteOne({ _id: eventId });
        revalidatePath('/artists');
        revalidatePath('/users');
        return (`Successfully deleted event with id ${eventId}`);
    } catch (error) {
        return {message: 'Failed to delete event'};
    }
};

/**
 * Update an event.
 * 
 * @param formData - The form data.
 * 
 * @returns The updated event.
 */
export async function updateEvent(formData: FormData) {
    await connectToDatabase();
    const eventId = formData.get('id');
    const event = await Event.findOne({ _id: eventId });
    const formerTitle = event?.title;
    const formerDescription = event?.description;
    const formerDate = event?.date;
    const formerAddress = event?.address;
    const formerCity = event?.city;
    const formerState = event?.state;
    const formerZip = event?.zip;

    try {
        await Event.updateOne({ _id: eventId },
            {
                title: formData.get('title') === '' ? formerTitle : formData.get('title'),
                description: formData.get('description') === '' ? formerDescription : formData.get('description'),
                date: formData.get('date') === '' ? formerDate : formData.get('date'),
                address: formData.get('address') === '' ? formerAddress : formData.get('address'),
                city: formData.get('city') === '' ? formerCity : formData.get('city'),
                state: formData.get('state') === '' ? formerState : formData.get('state'),
                zip: formData.get('zip') === '' ? formerZip : formData.get('zip')
            }
        );
        revalidatePath('/artists');
        revalidatePath('/users');
        return (`Successfully updated event with id ${eventId}`);
    } catch (error) {
        return {message: 'Failed to update event'};
    }
};

I started by importing the database event model, the revalidatePath function from Next.js, and a function that I created to connect to the MongoDB database.

The file contains the following server actions:

  • getEvents

    • Fetches all events from the database

    • It first connects to the database, then tries to fetch all events

    • The fetched data is returned after converting it to JSON format

    • If an error occurs, it logs the error and returns an empty array

  • createEvent

    • Creates a new event in the database

    • It first connects to the database, then extracts form data from the formData object

    • Next, it tries to create a new event and saves it

    • After creating the event, it revalidates the paths /artists and /users to purge cached data and update the events page (this step is necessary due to Next.js's client side router cache)

  • deleteEvent

    • Deletes an event from the database

    • It first connects to the database, then extracts the event's ObjectID from the id form data

    • It then tries to delete the event, and afterwards, it revalidates the paths /artists and /users

    • updateEvent

      • Updates an existing event in the database

      • It first connects to the database, then extracts the event's ObjectID from the formData object

      • Next, it fetches the current event details so that any empty fields remain the same

      • It then tries to update the event, only changing fields that are not empty in the form data

      • After updating the event, it revalidates the paths /artists and /users

Connecting to the database and designing the event schema and model

Within the lib directory, I also created a file called db.ts where I define my function to connect to the database.

import mongoose, { Connection } from 'mongoose';

// Cache the connection to avoid connecting to the database multiple times
let cachedConnection: Connection | null = null;

/**
 * Connect to the database.
 * 
 * @returns The connection to the database.
 */
export async function connectToDatabase() {
    if (cachedConnection) {
        return cachedConnection;
    }
    try {
        const cnx = await mongoose.connect(process.env.MONGODB_URI!);

        cachedConnection = cnx.connection;

        return cachedConnection;
    } catch (error) {
        console.log(error);
        throw error;
    };
};

First, I imported the mongoose library. Then, I created a variable named cachedConnection to store the database connection, preventing multiple reconnections.

The connectToDatabase function is an async function that first checks if cachedConnection is not null. If a cached connection exists, it returns the cached connection. Otherwise, it tries to connect to the MongoDB database and then caches the connection.

Within the src directory, I created another directory called models and added a file named Event.ts. This is where I defined the event schema and model.

import mongoose, { Document, Model } from 'mongoose';

export interface IEvent {
    title: string;
    description: string;
    date: string;
    address: string;
    city: string;
    state: string;
    zip: string;
}

export interface IEventDocument extends IEvent, Document {
    _id: mongoose.Schema.Types.ObjectId;
    createdAt: Date;
    updatedAt: Date;
};

// Create a new schema for events
const eventSchema = new mongoose.Schema<IEventDocument>(
    {
        _id: { type: mongoose.Schema.Types.ObjectId, auto: true},
        title: { type: String, required: true },
        description: { type: String, required: true },
        date: { type: String, required: true },
        address: { type: String, required: true },
        city: { type: String, required: true },
        state: { type: String, required: true },
        zip: { type: String, required: true }
    },
    { timestamps: true }
);

// Create a new model for events
const Event: Model<IEventDocument> = 
    mongoose.models?.Event || mongoose.model('Event', eventSchema);

export default Event;

First, I imported the mongoose library. Then, I created a TypeScript interface that defines the structure of an event object.

Next, I created an interface that extends both IEvent and Document from mongoose which includes all properties from IEvent and adds _id (the ObjectID), createdAt, and updatedAt.

I then defined the eventSchema which is a mongoose schema for the IEventDocument interface. The schema defines the structure of the event documents in the database.

Finally, I created the Event model with the eventSchema, and check if a model named Event already exists to avoid redefining the model.

Challenges and Solutions

Handling authentication

Authentication was supposed to be handled with the TikTok Login Kit using NextAuth.js, but I faced configuration issues that caused errors related to missing tokens and issuer definitions. After troubleshooting and checking GitHub, I decided to skip authentication for the initial release.

Connection to the database

During development, I didn't have any issues connecting to the database. However, during deployment, I faced problems with server actions and database requests. The server wasn't fetching data correctly from the database, leading to session timeouts.

The issues were resolved by:

  1. Ensuring the database connection was correctly established in each server action.

  2. Revalidating pages after server actions to clear cached data.

Future Enhancements

Potential Features to Add

Looking ahead, I plan to add several features to the project:

  • TikTok Authentication: Reattempting integration with the TikTok Login Kit.

  • Enhanced Location Input: Allowing users to input just the name of a location rather than the full address.

  • Notification System: Implementing a system to notify fans of upcoming concerts.

Conclusion

Building the TikTok Concerts platform using Next.js has been an exciting and rewarding experience.

By leveraging the powerful features of Next.js, TypeScript, React, and Tailwind CSS, I was able to create a dynamic and responsive web application that aims to enhance the live concert experience on TikTok Live. Despite facing challenges with authentication, I successfully implemented key features such as event scheduling, a global events map, and user-friendly interfaces for both artists and fans.

Looking ahead, I plan to add more features like TikTok authentication, enhanced location input, and a notification system to further improve the platform.

This project not only showcases the potential of modern web technologies but also aims to foster greater engagement and interaction between artists and their fans on TikTok.

Call to action

I hope you enjoyed learning about my project, TikTok Concerts. If you find it interesting and believe it can make a difference in enhancing live concert experiences on TikTok, I would greatly appreciate your support.

Please visit my website to explore the platform and see it in action: TikTok Concerts.

Additionally, if you like my project, please consider voting for my submission in the TikTok TechJam hackathon. Your vote can help bring more visibility to the project and support its development. You can vote for my submission here: Vote for TikTok Concerts.

Thank you for your support!

0
Subscribe to my newsletter

Read articles from Sirus Salari directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sirus Salari
Sirus Salari

Hello! My name is Sirus, I am currently enrolled at Oregon State University, and pursuing my B.S. in computer science. Before I even decided to pursue my B.S. in computer science, I had had some solid understanding in programming fundamentals from some courses I took during my first degree program, and also through self-teaching web development through Coursera, Udemy, and the Odin Project. If you're still reading, you're probably wondering what my first degree is in, and why I chose to pursue a second degree. I graduated from the University of California, Irvine in 2021 with a B.A. in psychology. When I started college, I had no idea what I wanted to do as a career, but I just knew that I was passionate about psychology. During my third year at UCI, I took a cognitive robotics course as part of my required classes for my major, and this was the first time I was ever exposed to coding, and my first time programming. I quickly fell in love with programming, and I decided to take an intro to programming course using Python during my fourth year at UCI. I continued to love and enjoy programming and learning about all the logic involved. Initially, my plan was to combine my interests for psychology and computer science into one by applying to PhD programs in an extremely niche field called "computational neuroscience". Essentially, this field focuses on mathematical/theoretical models of consciousness, the engineering of brain-computer-interfaces, and artificial intelligence/machine learning. I was denied from all 8 of the PhD programs I applied to. Instead of being defeated, I decided to continue pursuing my passion for programming. I started with just toying around with web development using HTML and CSS, then learned JavaScript, started making project websites, learned a lot from a Coursera course and Udemy course on web development, and learned a lot from following the Odin Project curriculum. Even though my passion and interest in programming never died, I found it hard to find the motivation to continue self-teaching. Ultimately, I still wanted to find a career that would involve programming. As a result, I started researching different options for people who wish to receive a second degree in computer science. I found Oregon State University as the perfect match for my needs, as it's entirely virtual while still having the same content as in-person classes, it's affordable compared to other options, and it allowed me to transfer all of my general education requirements from UCI, saving me tons of money and time on starting all over with another degree.