Build a room scheduling software with Next.js, BCMS, & Clerk

Build a room scheduling software with Next.js, BCMS, & Clerk thumbnails.jpeg
By Juliet Ofoegbu
Read time 10 min
Posted on 15 Aug 2025

You’ve probably booked a workspace or hotel room online through an app that lets people browse and book spaces or rooms in just a few clicks.

I set out to build something exactly like that—and I’m excited to show you how I pulled it off using BCMS (for content management), Next.js (for frontend), Clerk (for user authentication), ShadCN UI (for ready-made components), and TailwindCSS (for styling).

In this guide, I’ll walk you through everything from setting up the project to managing your content with BCMS and integrating authentication. I’ll build a clean, modern room booking app I like to call JRooms.

Room scheduling software tech stack:

  • Next.js – React-based framework for our frontend.

  • BCMS – Headless CMS to manage room and booking data.

  • Clerk – Handles user sign-up, login, and session management.

  • ShadCN UI – Components library for in-built components.

  • TailwindCSS – Utility-first CSS framework for styling.

Before getting started building the app, it's helpful to understand some basic BCMS concepts. BCMS is a headless content management system where your content is structured using templates (content types) and entries (actual content or records based on a template).

For example, in this tutorial, you'll work with templates like rooms and bookings, and each entry under these templates represents a specific room or booking.

If you're new to BCMS, you can read this quick guide to BCMS terms to get familiar.

Step 1: Project Setup for booking software

Start by creating a new Next.js app with TypeScript:

npm create next-app@latestcd jrooms

Press the enter key to use the default options for the Next.js installation. Install the essential packages, such as Clerk and ShadCN:

npm install @clerk/nextjs && npx shadcn@latest init

Select the “use --force” option if prompted to choose.

Project setup

Then install components from ShadCN using this command:

npx shadcn@latest add button badge card input separator sheet sidebar skeleton tooltip dialog label sonner

Also select the “use --force” option if prompted to choose.

This will automatically create a ui folder inside a newly created components folder in the src directory with the installed components in your project. It will also create a hooks folder with a use-mobile.ts file used to detect whether the user is currently viewing your app on a mobile device based on screen width. 

A lib folder will also be created inside the src directory. This lib folder will contain a utils.ts file to store reusable helper functions or small logic chunks that are used across multiple parts of your app.

Next, create a types folder inside the src directory. Create a file called index.d.ts inside this folder and paste the following lines of code:

declare type HeaderProps = {
  children: React.ReactNode;
  className?: string;
};

Finally, let’s install the last set of libraries and packages you’ll need:

npm install date-fns jspdf --force

The date-fns library is used for manipulating dates in JavaScript and the jsPDF library is used for generating PDFs.

Step 2: Creating Components

In your components folder, create two files and paste the following lines of code inside their corresponding files.

1. app-sidebar.tsx file:

This file defines the left-hand sidebar navigation menu using custom UI components from ShadCN. It includes:

  • A header with the app name (JRooms) and version.

  • A list of navigation items like "Available Rooms" and "Booked Rooms" with active link highlighting based on the current route.

  • It uses usePathname() from Next.js to determine which menu item is currently active.

  • The sidebar is designed to be floating and collapsible to make it mobile-friendly and visually clean.

// src/components/app-sidebar.tsx
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar";
import Link from "next/link";
import Image from "next/image";
const navItems = [
  {
    title: "Available Rooms",
    url: "/",
  },
  {
    title: "Booked Rooms",
    url: "/booked",
  },
];
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
  const pathname = usePathname();
  return (
    <Sidebar variant="floating" {...props}>
      <SidebarHeader>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton size="lg" asChild>
              <Link href="/">
                <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
                  <Image
                    src="/logo-1.png"
                    width={20}
                    height={20}
                    alt="JRooms"
                    className="w-full h-full object-contain"
                  />
                </div>
                <div className="flex flex-col gap-0.5 leading-none">
                  <span className="font-semibold">JRooms</span>
                </div>
              </Link>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarMenu className="gap-2">
            {navItems.map((item) => (
              <SidebarMenuItem key={item.title}>
                <SidebarMenuButton asChild isActive={pathname === item.url}>
                  <a href={item.url}>{item.title}</a>
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroup>
      </SidebarContent>
    </Sidebar>
  );
}

Here’s how the sidebar and header section look:

sidebar and header section

2. Header.tsx file:

This component is responsible for displaying the top bar of the app. It sits above the main content area and includes:

  • A sidebar toggle button on the left (helpful on smaller screens).

  • A user menu on the right powered by Clerk’s <UserButton />, which shows the signed-in user's avatar and allows them to sign out or manage their account.

This Clerk-powered authentication will be discussed later on in the article:

Clerk-powered authentication

It ensures a clean and consistent layout at the top of every page where it appears.

// src/components/Header.tsx
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
const Header = ({ children, className }: HeaderProps) => {
  return (
    <div
      className={cn(
        "min-h-20 min-w-full flex-nowrap bg-blue-800 flex w-full items-center justify-between gap-2 px-4",
        className
      )}
    >
      <Link href="/" className="flex items-center md:flex-1">
        <Image
          src="/logo-1.png"
          alt="Logo with name"
          width={60}
          height={32}
          className="hidden md:block p-1"
        />
        <Image
          src="/logo-1.png"
          alt="Logo"
          width={32}
          height={32}
          className="mr-2 md:hidden"
        />
        <span className="uppercase font-extrabold text-2xl">JRooms</span>
      </Link>
      {children}
    </div>
  );
};
export default Header;

Step 3: Set Up BCMS

1. Create an account

Go to https://thebcms.com/ and create a free account or log in to your account if you already have one. Once you're in:

  • Create a new project instance.

  • Add two new templates called Rooms and Bookings.

2. Define your ‘Rooms’ template

Inside the Rooms template, define the properties you want each room to have:

  • Title (string)

  • Slug (string)

  • Capacity (number)

  • Description (string)

  • Category (enumeration)

  • Price (number)

  • Image (media)

  • Location (string)

  • Booking time (date)

  • Duration (number)

  • Amenities (Enumeration)

3. Define your ‘Bookings’ template

Inside the Bookings template, define the properties you want each booking to have:

  • Title (string)

  • Slug (string)

  • Userid (string)

  • Paymentid (string)

  • Phone (string)

  • Email (string)

  • Duration (number)

  • Time (string)

  • Rooms (Entry Pointer). This will point the ‘bookings’ template to the ‘rooms’ template

4. Create entries for ‘Rooms’ template

Inside your ‘Rooms’ template, create entries like this:

‘Rooms’ template

5. Get API credentials

Go to Settings > API Keys in your BCMS dashboard and generate a new key by inputting a name and description. The keys you’ll need for the project are:

  • Organization ID

  • Instance ID

  • API Key ID

  • API Key Secret

6. Grant access permissions

Grant access permission for your generated API keys to allow your frontend to fetch data from the BCMS backend. Tick all the checkboxes for the Bookings template and tick the Can get checkbox for the Rooms template, as shown in the image below.

Save the changes by hitting the Update key button.

API Key

7. Install BCMS packages

To install the BCMS client and its packages into your Next.js project, use this command:

npm i --save @thebcms/cli @thebcms/client @thebcms/components-react

This will install the BCMS client and its packages into your Next.js project.

Update your package.json file by modifying the scripts section to this:

"scripts": {
  "dev": "bcms --pull types --lng ts && next dev",
  "build": "bcms --pull types --lng ts && next build",
  "start": "bcms --pull types --lng ts && next start",
  "lint": "bcms --pull types --lng ts && next lint"
}

In your tsconfig.json file, set moduleResolution to Node:

"moduleResolution":"Node"

Next, create a bcms.config.cjs file in the root of your project and paste these lines of code:

/**
 * @type {import('@thebcms/cli/config').BCMSConfig}
 */
module.exports = {
  client: {
    orgId: process.env.BCMS_ORG_ID || "input-your-organization-id-here",
    instanceId: process.env.BCMS_INSTANCE_ID || "input-your-instance-id-here",
    apiKey: {
      id: process.env.BCMS_API_KEY_ID || "input-your-key-id-here",
      secret: process.env.BCMS_API_KEY_SECRET || "input-your-key-secret-here",
    },
  },
};

8. Initialize the BCMS client

To initialize the BCMS client, create another file inside the generated lib folder, name it bcms.ts, and paste these lines of code in it:

// lib/bcms.ts
import { Client } from '@thebcms/client';
export const bcms = new Client(
  process.env.BCMS_ORG_ID || '',
  process.env.BCMS_INSTANCE_ID || '',
  {
    id: process.env.BCMS_API_KEY_ID || '',
    secret: process.env.BCMS_API_KEY_SECRET || '',
  },
  {
    injectSvg: true,
  },
);

Moving on to authenticating the application.

Step 4: Add Authentication with Clerk

First, set up your app in the Clerk dashboard.

After registering:

1. Create an environment variable file (.env.local) in the root directory of your project and add your Clerk Publishable Key and Secret Key to it:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_publishable_key
CLERK_SECRET_KEY=your_secret_key

2. Create a middleware.ts file in the root directory of your project. This file defines a middleware that runs before a request is handled and is used here to protect certain routes with Clerk authentication.

Paste the following lines of code inside the file:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
// Define protected routes
const isProtectedRoute = createRouteMatcher([
  '/booked(.*)',
  '/rooms(.*)',
]);
export default clerkMiddleware((auth, req) => {
  if (isProtectedRoute(req)) {
    // Protect the specified routes
    auth.protect();
  }
});
export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
};

Code explanation:

  • The imports clerkMiddleware wraps your middleware to integrate Clerk’s auth and createRouteMatcher helps define which routes should be protected.

  • Any route that matches /booked or /rooms (and subpaths like /booked/123) will be protected. The (.*) means any subpath under these routes is included.

  • The middleware checks if the incoming request matches a protected route. If it does, auth.protect() ensures the user is signed in. Otherwise, they’re redirected to sign in.

  • The middleware config defines which paths the middleware should run on. It skips internal files like /static, /favicon.ico, .css, .js, etc. It runs for all routes except static assets and all API routes (like /api/bookings, etc.)

3. Wrap your app with ClerkProvider in the app/layout.tsx file:

// app/layout.tsx
import { Inter as FontSans } from "next/font/google";
import { cn } from "@/lib/utils";
import './globals.css';
import { Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
// import { dark } from "@clerk/themes"
const fontSans = FontSans({
  subsets: ["latin"],
  variable: "--font-sans",
});
export const metadata: Metadata = {
  title: 'JRooms',
  description: 'Book your rooms online.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider
      appearance={{
        // baseTheme: dark,
        variables: {
          colorPrimary: "#3371FF",
          fontSize: '16px'
        },
      }}
    >
      <html lang="en" suppressHydrationWarning>
        <body
          className={cn(
            "min-h-screen font-sans antialiased",
            fontSans.variable
          )}
        >
          {children}
        </body>
      </html>
    </ClerkProvider>
  );
}

Code Explanation:

  • This is your global layout that wraps the entire app, regardless of route.

  • It wraps your app in Clerk and provides global context for Clerk, which is necessary for any Clerk UI components (UserButton, SignIn, etc.) to function.

  • Passes in appearance customization like colorPrimary and uses Google’s Inter font and imports globals.css styles.

4. Create the auth pages:

Inside the app directory, create a folder called (auuth). Create a folder inside it and name it sign-up. Create a folder inside this folder called [[...sign-up]] and finally, create a page.tsx file inside it.

Input the following lines of code inside this page.tsx file:

// app/(auth)/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
  return (
    <main className="flex h-screen items-center justify-center">
      <SignUp />
    </main>
  );
}

Just like you did for the sign-up, inside the (auth) folder, create another folder named sign-in. Create a folder inside this folder called [[...sign-in]] and finally, create a page.tsx file inside it.

Input the following lines of code inside this page.tsx file:

// app/(auth)/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
  return (
    <main className="flex h-screen items-center justify-center">
      <SignIn />
    </main>
  );
}

Code explanation:

In the authentication folders (sign-in and sign-up):

  • You’re rendering Clerk’s prebuilt UI components like <SignIn /> and <SignUp />.

  • They auto-handle redirection after successful sign-in or sign-up.

That’s all for the authentication. Let’s fetch the room data from the BCMS backend and display it.

Step 5: Fetch room data

Create your home page and protect it with currentUser from Clerk. In your app directory, create a (root) folder and a page.tsx file inside this folder. This will be the home page of the application.

It acts as a server-side check with currentUser() to ensure only signed-in users see the rooms dashboard. It also fetches data from BCMS and displays it inside a layout.

Paste these lines of code in the file:

// app/(root)/page.tsx
import { AppSidebar } from "@/components/app-sidebar";
import { bcms } from "../../lib/bcms";
import { RoomsEntry } from "@/bcms/types/ts/entry/rooms";
import { currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { RoomCard } from "@/components/room-card";
export default async function Page() {
  const clerkUser = await currentUser();
  if (!clerkUser) redirect("/sign-in");
  const rooms = (await bcms.entry.getAll("rooms")) as RoomsEntry[];
  return (
    <SidebarProvider
      style={
        {
          "--sidebar-width": "19rem",
        } as React.CSSProperties
      }
    >
      <AppSidebar />
      <SidebarInset>
        {/* Header */}
        <header className="flex h-16 bg-white border-b shrink-0 items-center justify-between px-4">
          <SidebarTrigger className="-ml-1" />
          <div className="ml-auto">
            <SignedIn>
              <UserButton />
            </SignedIn>
          </div>
        </header>
        {/* Main content */}
        <main className="p-6 flex flex-col gap-8">
          <div className="mb-6 flex items-center justify-between">
            <div>
              <h1 className="text-3xl font-bold">Available Rooms</h1>
              <p className="text-muted-foreground">
                Browse and book our selection of rooms
              </p>
            </div>
          </div>
          <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
            {rooms.map((room: RoomsEntry) => (
              <RoomCard key={room._id} room={room} />
            ))}
          </div>
        </main>
      </SidebarInset>
    </SidebarProvider>
  );
}

Code explanation:

  • This is the main page for authenticated users — your app's dashboard/home after login.

  • It checks if the user is logged in. If no Clerk user is found (i.e., not signed in), the user is redirected to /sign-in.

  • It uses your custom BCMS client to fetch all entries in the rooms content type from BCMS.

  • The page is wrapped in a <SidebarProvider> to enable the sidebar feature.

  • <AppSidebar /> from ShadCN components is rendered.

  • The header includes a sidebar toggle button and Clerk’s <UserButton /> (only shown when signed in).

  • Inside the main content area, it maps over rooms and renders a <RoomCard /> for each room. These entries are passed to render individual room cards.

Display Room Cards

This component will be displayed as a card containing some details about each room.

In your components folder, create another file called room-card.tsx and paste the following lines of code inside it:

// src/components/room-card.tsx
import { bcms } from "@/lib/bcms";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Users } from "lucide-react";
import { RoomsEntry } from "../../bcms/types/ts";
import { BCMSImage } from "@thebcms/components-react";
import { formatCategory } from "@/lib/utils";
interface RoomCardProps {
  room: RoomsEntry;
}
export function RoomCard({ room }: RoomCardProps) {
  return (
    <Link href={`/rooms/${room.meta.en?.slug}`} prefetch={true}>
      <Card className="h-full overflow-hidden border-2 p-1 transition-all hover:shadow-md">
        <div className="relative aspect-[4/3] overflow-hidden rounded-t-lg">
          <Badge className="absolute right-2 top-2 z-10">
            {formatCategory(room?.meta?.en?.category)}
          </Badge>
          {room?.meta?.en?.image && (
            <BCMSImage
              media={room.meta.en.image}
              clientConfig={bcms.getConfig()}
              className="w-full h-full object-cover transition-transform hover:scale-105"
            />
          )}
        </div>
        <CardContent className="px-4">
          <h3 className="text-xl font-semibold">{room?.meta?.en?.title}</h3>
          <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
            {room?.meta?.en?.description}
          </p>
          <div className="mt-2">
            <div className="text-sm

Code Explanation:

  • Each card links to its specific room’s detail page using the slug from the BCMS data.

  • prefetch={true} tells Next.js to preload the page in the background for faster navigation.

  • When we added values to the category property in the rooms template in BCMS, the values were automatically uppercase and separated by an underscore. Now, when it's rendered in the frontend, we want it to be capitalized and the words in each value separated, and we do that by formatting this property. formatCategory() is a custom utility function (in the /lib/utils) that formats the categories (e.g., splitting, uppercase, etc.).

  • It is then wrapped in a styled <Badge> from the ShadCN UI library.

  • Instead of using a native <img /> I am using BCMSImage from BCMS's official components package. It handles optimization and rendering via the BCMS CDN using the clientConfig.

This component then displays a single room listing in a card layout. It's reusable and designed to show:

  • A room image, category label, title, short description, and link to view more.

  • Additional info like room space: capacity (people), price per hour, and max duration.

  • It uses the BCMSImage component to render media from BCMS efficiently.

  • The card is interactive, with a clickable link that routes to the room’s detailed page.

Update utils function

Update the utils function in the lib folder to add a custom utility function that formats the categories by separating each word in the category and turning it to uppercase.

Add this to the lib/utils file:

/**
 * Converts "CONFERENCE_HALL" to "Conference Hall"
 * @param category - A raw category string (e.g. "CONFERENCE_HALL")
 * @returns A formatted category string (e.g. "Conference Hall")
 */
export function formatCategory(category?: string) {
  return category
    ? category
        .toLowerCase()
        .split("_")
        .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
        .join(" ")
    : "";
}

Save your file and go to the localhost on your browser. Refresh it to see the changes.

The home page should look like this now:

home page

Displaying Individual Rooms

I want users to be able to click each room card and see more details about the room so that they can proceed to room reservation.

In your rooms folder, create a page.tsx file and paste these lines of code:

// app/(root)/rooms/page.tsx
import { redirect } from 'next/navigation';
import { currentUser } from '@clerk/nextjs/server';
export default async function RoomsPage() {
  // Optional: Check if user is authenticated
  const user = await currentUser();
  
  if (!user) {
    redirect('/sign-in');
  }
  // Redirect to home page
  redirect('/');
}

Code explanation:

This page will serve as the main page for the /rooms route. Here's what it does:

  • It checks if a user is authenticated using @clerk/nextjs/server's currentUser() function.

  • If the user is not authenticated, it redirects them to the sign-in page.

  • If the user is authenticated, it redirects them to the home page (/)

Then, you’ll create another folder in your app/(root) folder called rooms. Create another folder inside this rooms folder and name it [slug]. Now create a page.tsx file inside this [slug] folder and paste these lines of code:

// app/(root)/rooms/[slug]/page.tsx
import { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, Clock, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { notFound } from "next/navigation";
import { bcms } from "@/lib/bcms";
import {
  BCMSEntryContentParsedItem,
  RoomsEntry,
  RoomsEntryMetaItem,
} from "@/bcms/types/ts";
import { BCMSImage } from "@thebcms/components-react";
import { AppSidebar } from "@/components/app-sidebar";
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";
import { formatCategory } from "@/lib/utils";
import { BookingModal } from "@/components/booking-modal";
type Props = {
  params: {
    slug: string;
  };
};
export async function generateStaticParams() {
  const rooms = (await bcms.entry.getAll("rooms")) as RoomsEntry[];
  return rooms.map((room) => {
    const meta = room.meta.en as RoomsEntryMetaItem;
    return {
      slug: meta.slug,
    };
  });
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const room = (await bcms.entry.getBySlug(slug, "rooms")) as RoomsEntry;
  if (!room) {
    return notFound();
  }
  const roomEntryMeta = room.meta.en as RoomsEntryMetaItem;
  const pageTitle = `${roomEntryMeta?.title} - Great Room`;
  return {
    title: pageTitle,
    openGraph: {
      title: pageTitle,
    },
    twitter: {
      title: pageTitle,
    },
  };
}
const RoomDetailPage = async ({ params }: Props) => {
  // Fetch all rooms
  const rooms = (await bcms.entry.getAll("rooms")) as RoomsEntry[];
  console.log("Rooooooommmsss:", rooms);
  const { slug } = await params;
  console.log("Slug:", slug);
  // Find the current room
  const room = rooms.find((e) => e.meta.en?.slug === slug);
  if (!room) {
    return notFound();
  }
  // Prepare room data
  const data = {
    meta: room.meta.en as RoomsEntryMetaItem,
    content: room.content.en as BCMSEntryContentParsedItem[],
  };
  return (
    <SidebarProvider
      style={
        {
          "--sidebar-width": "19rem",
        } as React.CSSProperties
      }
    >
      <AppSidebar />
      <SidebarInset>
        <div className="container mx-auto p-6">
          <div className="mb-6 flex items-center gap-4">
            <SidebarTrigger className="md:hidden" />
            <Link href="/">
              <Button variant="outline" size="icon" className="h-8 w-8">
                <ArrowLeft className="h-4 w-4" />
                <span className="sr-only">Back</span>
              </Button>
            </Link>
            <h1 className="text-2xl font-bold md:text-3xl">
              {data?.meta?.title}
            </h1>
            <Badge className="ml-auto">
              {formatCategory(data?.meta?.category)}
            </Badge>
          </div>
          <div className="grid gap-6 md:grid-cols-3">
            {/* Left Section */}
            <div className="md:col-span-2">
              <div className="relative aspect-video overflow-hidden rounded-lg">
                {data?.meta?.image && (
                  <BCMSImage
                    media={data?.meta?.image}
                    clientConfig={bcms.getConfig()}
                    className="w-full h-full object-cover transition-transform hover:scale-105"
                  />
                )}
              </div>
              <div className="mt-6">
                <h2 className="text-xl font-semibold">Description</h2>
                <div className="mt-2 whitespace-pre-line text-muted-foreground">
                  {data?.meta?.description}
                </div>
              </div>
              <div className="mt-6">
                <h2 className="text-xl font-semibold">Features</h2>
                <ul className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
                  {data?.meta?.amenities &&
                    data?.meta?.amenities.map((feature) => (
                      <li key={feature} className="flex items-center gap-2">
                        <div className="h-2 w-2 rounded-full bg-primary" />
                        <span>{formatCategory(feature)}</span>
                      </li>
                    ))}
                </ul>
              </div>
            </div>
            {/* Right Section */}
            <div>
              <div className="rounded-lg border p-4">
                <h2 className="text-xl font-semibold">Booking Information</h2>
                <div className="mt-4 space-y-4">
                  <div className="flex justify-between">
                    <div className="flex items-center gap-2">
                      <Users className="h-5 w-5 text-muted-foreground" />
                      <span>Capacity</span>
                    </div>
                    <span className="font-medium">
                      {data?.meta?.capacity} people
                    </span>
                  </div>
                  <div className="flex justify-between">
                    <div className="flex items-center gap-2">
                      <Clock className="h-5 w-5 text-muted-foreground" />
                      <span>Available for</span>
                    </div>
                    <span className="font-medium">
                      Up to {data?.meta?.duration} hours
                    </span>
                  </div>
                  <div className="flex justify-between">
                    <span>Price per hour</span>
                    <span className="font-medium">${data?.meta?.price}</span>
                  </div>
                  <div className="pt-4">
                    <BookingModal
                      roomId={room?._id}
                      roomTitle={data?.meta?.title}
                      maxHours={data?.meta?.duration}
                      roomTemplateId={room?.templateId}
                      roomPrice={data?.meta?.price}
                    />
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  );
};
export default RoomDetailPage;

Code explanation:

  • The generateStaticParams() function tells Next.js to pre-render a static page for each room slug. This helps with static site generation (SSG) for each room.

  • The generateMetedata() function generates dynamic <title> and Open Graph/Twitter metadata based on the room. If no room is found, it calls notFound() to trigger a 404 page.

  • The main component RoomDetailPage accepts the params object containing the slug. It fetches all rooms from the BCMS entry.getAll("rooms") and finds the room matching the current slug. If it is not found, it returns 404. If it’s found, it parses the metadata and content.

  • Then the UI is rendered. Inside a SidebarProvider, it renders the layout, including the top section, left column, and right column (card UI).

  • The BookingModal component that’s being imported handles collecting user details and posting to the /api/book endpoint.

This is how each individual room page looks now:

individual room page

Loading state for loading individual rooms

To create a simple loading state to handle the loading of each room, create a loading.tsx file in the [slug] folder and enter these lines of code:

// app/(root)/rooms/[slug]/loading.tsx
function Loading() {
  return (
    <div className="h-screen w-full flex items-center justify-center">
      <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
    </div>
  );
}
export default Loading;

Step 6: Booking a room

To be able to book a room, you’ll need to do two things. First, create an API endpoint that sends submitted details to the backend, and then create a booking model to handle the submission.

API endpoint for creating bookings in the room scheduling software

I’ll create the API route to accept booking form data from a POST request, validate it, create a new booking entry in the BCMS Bookings template, and return success or a detailed error message.

In your app directory, create an api folder, then a create-booking folder and finally, a route.ts file in it. Paste these lines of code in the route file:

// app/api/create-booking/route.ts
import { NextResponse } from "next/server";
import { bcms } from "@/lib/bcms";
import { auth } from "@clerk/nextjs/server";
export async function POST(request: Request) {
  const { userId } = await auth();
  try {
    // Parse the request body
    const body = await request.json();
    // Validate required fields
    if (
      !body.title ||
      !body.slug ||
      !body.paymentid ||
      !body.email ||
      !body.phone ||
      !body.time ||
      !body.duration ||
      !body.rooms?.entryId ||
      !body.rooms?.templateId
    ) {
      return NextResponse.json(
        { success: false, error: "Missing required fields in request body" },
        { status: 400 }
      );
    }
    // Convert duration to number
    const duration = Number(body.duration);
    if (isNaN(duration)) {
      return NextResponse.json(
        { success: false, error: "Duration must be a valid number" },
        { status: 400 }
      );
    }
    // Create the booking
    const newBooking = await bcms.entry.create("bookings", {
      content: [],
      statuses: [],
      meta: [
        {
          lng: "en",
          data: {
            title: body.title,
            slug: body.slug,
            userid: userId || "null",
            paymentid: body.paymentid,
            email: body.email,
            phone: body.phone,
            time: body.time,
            duration: duration, // Use the converted number
            rooms: {
              entryId: body.rooms.entryId,
              templateId: body.rooms.templateId,
            },
          },
        },
      ],
    });
    return NextResponse.json({ success: true, data: newBooking });
  } catch (error) {
    console.error("Error creating booking:", error);
    return NextResponse.json(
      { success: false, error: "Failed to create booking" },
      { status: 500 }
    );
  }
}

Code explanation:

  • NextResponse is used to return JSON responses with status codes in Next.js App Router API routes.

  • Then, there’s the configured instance of the BCMS SDK client from the local file lib/bcms.ts that provides methods to interact with BCMS, like entry.create for creating entries.

  • Then, we have the exported POST request handler that is invoked when a POST request hits this route (/api/create-booking).

  • The const = body… extracts the JSON body from the incoming request, which should contain booking info sent by the frontend form (from BookingModal component).

  • The fields are validated to ensure all necessary fields are provided before proceeding. If anything is missing (like email, rooms.entryId, etc.), it returns a 400 Bad Request with an error.

  • We define a function that converts duration from string to number. If it’s not a number, it returns a 400 with an error.

  • Then we define a function that creates a new entry in the Bookings template inside BCMS. It connects the booking to a specific room entry via entryId and templateId.

  • Finally, it returns 200 OK with the newly created booking entry if everything worked or a 500 Internal Server Error if anything fails during parsing, validation, or booking creation (e.g., network error)

Booking Modal

This component handles user interactions (inputting info, selecting time and duration), data submission to the /api/create-booking API on form submission, and the ability to generate a downloadable PDF receipt for the booking.

This modal lets users book a room by submitting their details and the room booking duration. It also checks if the room is already booked and, if so, disables the booking form and shows a Download Receipt button instead.

In your components folder, create a file called booking-modal.tsx and paste these lines of code:

// src/components/booking-modal.tsx
"use client";
import type React from "react";
import { jsPDF } from "jspdf";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Download } from "lucide-react";
import { BookingsEntry } from "@/bcms/types/ts";
interface BookingModalProps {
  roomId: string;
  roomTemplateId: string;
  roomTitle: string;
  maxHours: number | undefined;
  roomPrice: number | undefined;
}
export function BookingModal({
  roomId,
  roomTitle,
  maxHours,
  roomTemplateId,
  roomPrice,
}: BookingModalProps) {
  const [open, setOpen] = useState(false);
  const [loading, setLoading] = useState(false);
  const [isBooked, setIsBooked] = useState(false);
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    phone: "",
    duration: 1,
  });
  // Check booking status and expiration
  useEffect(() => {
    const checkBookingStatus = async () => {
      try {
        const response = await fetch("/api/bookings");
        if (response.ok) {
          const bookings = await response.json();
          // Find all bookings for this room (not just the most recent)
          const roomBookings = bookings.filter(
            (booking: BookingsEntry) =>
              booking.meta?.en?.rooms?._id === roomId
          );
          // Check if there's any active booking (not expired)
          const hasActiveBooking = roomBookings.some(
            (booking

Code explanation:

1. This component accepts:

  • roomId: The unique ID of the room (used for backend reference).

  • roomTemplateId: The template ID from BCMS (used to link entry).

  • roomTitle: For displaying in the form modal.

  • maxHours: Maximum number of hours this room can be booked.

  • roomPrice: Price per hour of the room.

2. Set states that control whether the modal is open or closed and indicate if the booking request is being submitted. Also, set a state that uses a boolean to check if the room is already booked and store the name, email, phone, and duration inputted by the user.

3. The useEffect function is the booked status and expiration checker. On component mount and every 60 seconds, it:

  • Fetches all bookings from the /api/bookings API, which I’ll create later on

  • Filters bookings for the current room

  • Checks if any booking is still active (not expired)

  • If a booking has expired, removes its info from localStorage

  • If an active booking exists, it disables booking form

4. The handleChange function updates the form state on user input and the handleSubmit() function:

  • Sends booking data to /api/create-booking

  • Stores booking in localStorage using booking-${roomId} as key

  • Shows toast notifications on success/failure

  • Closes the modal after successful submission.

5. Then there is the handleDownloadReceipt function. If a room is booked, the user can download a PDF receipt with room booking details. It uses jsPDF to:

  • Set title, room, user, and payment info

  • Calculate total cost (price × duration)

  • Display a thank-you footer

  • Save the PDF with a name like booking-receipt-user_xyz.pdf

6. The UI structure of this page, when rendered, works like this:

  • When a room is not booked, the Book this room button is displayed and opens a modal when clicked. This modal contains a form with fields for full name, email, phone number and duration, along with a Cancel button, which closes the modal and a Proceed button, which triggers submission and submits bookings.

  • When a room is already booked, it displays a disabled This room is already booked button and a Download PDF Receipt button instead.

NB: Booking expiration is time-based, meaning the user can’t double-book a room within the duration they have already selected. The booking data is cached in localStorage so it persists until expired.

Step 7: Viewing Booked Room Details

You’d want users to be able to view details of any room they’ve booked.

We’ll create a page that will serve as the main view for a user's booked rooms. It will authenticate the user with Clerk (like the available rooms listing), fetch rooms and bookings from BCMS, filter bookings by the current user, and match them to their corresponding rooms. It will then calculate expiration times and total prices and display the results in a grid layout within a custom sidebar.

Create another folder in your app/(root) folder called booked. Create a page.tsx file in this folder and paste these lines of code.

// app/(root)/booked/page.tsx
import { AppSidebar } from "@/components/app-sidebar";
import { bcms } from "@/lib/bcms";
import { RoomsEntry } from "@/bcms/types/ts/entry/rooms";
import { currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { BookingsEntry } from "@/bcms/types/ts";
import { BookedRoomCard } from "@/components/booked-room-card";
export default async function Page() {
  const clerkUser = await currentUser();
  if (!clerkUser) redirect("/sign-in");
  const rooms = (await bcms.entry.getAll("rooms")) as RoomsEntry[];
  const bookings = (await bcms.entry.getAll("bookings")) as BookingsEntry[];
  // Filter bookings to only those belonging to the current user
  const userBookings = bookings.filter(
    (booking) => booking.meta?.en?.userid === clerkUser.id
  );
  // Create a map of room IDs to their corresponding room data
  const roomMap = new Map<string, RoomsEntry>();
  rooms.forEach((room) => roomMap.set(room._id, room));
  // Create a map of room IDs to their corresponding user bookings
  const bookingsByRoomId = new Map<string, BookingsEntry[]>();
  userBookings.forEach((booking) => {
    const roomId = booking.meta?.en?.rooms?._id; // Or entryId depending on schema
    if (roomId) {
      if (!bookingsByRoomId.has(roomId)) {
        bookingsByRoomId.set(roomId, []);
      }
      bookingsByRoomId.get(roomId)?.push(booking);
    }
  });
  // Filter rooms to only include those that are booked by this user
  const bookedRooms = rooms.filter((room) =>
    bookingsByRoomId.has(room._id)
  );
  // Process each booked room to calculate expiration and total price
  const bookedRoomsWithDetails = bookedRooms.map((room) => {
    const roomBookings = bookingsByRoomId.get(room._id) || [];
    const latestBooking = roomBookings[0]; // Assuming the first one is the most recent
    const roomPrice = room.meta?.en?.price || 0;
    const createdAt = latestBooking.createdAt
      ? new Date(latestBooking.createdAt)
      : new Date();
    const durationHours = latestBooking.meta?.en?.duration || 1;
    const expiresAt = new Date(
      createdAt.getTime() + durationHours * 60 * 60 * 1000
    );
    const totalPrice = roomPrice * durationHours;
    return {
      room,
      bookingDetails: {
        createdAt,
        expiresAt,
        totalPrice,
        duration: durationHours,
        bookingId: latestBooking._id,
      },
    };
  });
  return (
    <SidebarProvider
      style={
        {
          "--sidebar-width": "19rem",
        } as React.CSSProperties
      }
    >
      <AppSidebar />
      <SidebarInset>
        {/* Header */}
        <header className="flex h-16 bg-white border-b shrink-0 items-center justify-between px-4">
          <SidebarTrigger className="-ml-1" />
          <div className="ml-auto">
            <SignedIn>
              <UserButton />
            </SignedIn>
          </div>
        </header>
        {/* Main content */}
        <main className="p-6 flex flex-col gap-8">
          <div className="mb-6 flex items-center justify-between">
            <div className="gap-y-2">
              <h1 className="text-3xl font-bold">My Booked Rooms</h1>
              <p className="text-muted-foreground">
                Your upcoming room bookings
              </p>
            </div>
          </div>
          {bookedRoomsWithDetails.length > 0 ? (
            <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
              {bookedRoomsWithDetails.map(({ room, bookingDetails }) => (
                <BookedRoomCard
                  key={bookingDetails.bookingId}
                  room={room}
                  createdAt={bookingDetails.createdAt}
                  expiresAt={bookingDetails.expiresAt}
                  totalPrice={bookingDetails.totalPrice}
                  duration={bookingDetails.duration}
                />
              ))}
            </div>
          ) : (
            <div className="flex flex-col items-center justify-center py-12">
              <p className="text-muted-foreground">
                You haven&apos;t booked any rooms yet
              </p>
            </div>
          )}
        </main>
      </SidebarInset>
    </SidebarProvider>
  );
}

Code Explanation:

  • First, I imported the necessary components I needed.

  • The code then fetches the currently signed-in user. If no user is found, it redirects to /sign-in, which is the login page.

  • Fetches all room and booking entries from BCMS and casts them to the correct types as defined.

  • Filters the list of all bookings to only include those made by the current user by their user ID. This ensures that users can only view their own booked rooms and not the rooms booked by other users.

  • Creates a map for quick lookup of rooms by their _id.

  • Groups bookings by room ID. This allows you to easily get all bookings for a specific room.

  • The next line of code filters the full list of rooms down to only those that have a booking made by the current user.

  • For each booked room, the room price, booking creation time, duration in hours, expiration time (start time + duration), total price, and booking ID (used as key) are all extracted.

  • Finally, the UI is rendered (the sidebar, header, and main content of the page, which in this case is the booked room card). The main booked room page displays a grid of booked room cards (via BookedRoomCard) if there are bookings or a fallback message if there are no bookings.

This is what the booked rooms page will look like:

booked rooms page

Display Booked Room Cards

This BookedRoomCard component is a reusable UI card that displays detailed information about a booked room, including booking date, duration, expiration, price paid, and more.

In your components folder, create a file called booked-room-card.tsx and paste the following lines of code inside it:

// src/components/booked-room-card.tsx
import { bcms } from "@/lib/bcms";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Users } from "lucide-react";
import { RoomsEntry } from "@/bcms/types/ts";
import { BCMSImage } from "@thebcms/components-react";
import { formatCategory } from "@/lib/utils";
import { formatDistanceToNow, format, isAfter } from "date-fns";
interface BookedRoomCardProps {
  room: RoomsEntry;
  createdAt: Date;
  expiresAt: Date;
  totalPrice: number;
  duration: number;
}
export function BookedRoomCard({
  room,
  createdAt,
  expiresAt,
  totalPrice,
  duration,
}: BookedRoomCardProps) {
  // Check if booking has expired
  const isExpired = isAfter(new Date(), expiresAt);
  // Format dates
  const formattedCreatedAt = format(createdAt, "MMM d, yyyy 'at' h:mm a");
  const formattedExpiresAt = format(expiresAt, "MMM d, yyyy 'at' h:mm a");
  const expiresIn = isExpired
    ? "Expired"
    : formatDistanceToNow(expiresAt, { addSuffix: true });
  return (
    <Link href={`#`} prefetch={true}>
      <Card className="h-full overflow-hidden border-2 p-1 transition-all hover:shadow-md">
        <div className="relative aspect-[4/3] overflow-hidden rounded-t-lg">
          <Badge className="absolute right-2 top-2 z-10">
            {formatCategory(room?.meta?.en?.category)}
          </Badge>
          {room?.meta?.en?.image && (
            <BCMSImage
              media={room.meta.en.image}
              clientConfig={bcms.getConfig()}
              className="w-full h-full object-cover transition-transform hover:scale-105"
            />
          )}
        </div>
        <CardContent className="px-4">
          <h3 className="text-xl font-semibold">{room?.meta?.en?.title}</h3>
          <div className="mt-1 text-sm text-muted-foreground">
            Booked on: {formattedCreatedAt}
          </div>
          <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
            {room?.meta?.en?.description}
          </p>
          <div className="mt-2">
            <div className="text-sm text-primary hover:underline">
              See more
            </div>
          </div>
        </CardContent>
        <CardFooter className="flex flex-col justify-between gap-y-2 items-start border-t p-4 text-sm">
          <div className="flex items-center gap-1">
            <Users className="h-4 w-4" />
            <span>Capacity: {room?.meta?.en?.capacity} people</span>
          </div>
          <div className="flex items-center gap-1">
            <span className="font-medium">Duration:</span>
            <span>
              {duration} hour{duration !== 1 ? "s" : ""}
            </span>
          </div>
          <div className="font-medium">
            Total Amount Paid: ${totalPrice.toFixed(2)}
          </div>
          <div
            className={`${
              isExpired
                ? "text-destructive font-medium"
                : "text-muted-foreground"
            }`}
          >
            {isExpired ? (
              <span>Expired</span>
            ) : (
              <span>
                Expires {expiresIn} ({formattedExpiresAt})
              </span>
            )}
          </div>
        </CardFooter>
      </Card>
    </Link>
  );
}

Code explanation:

1. I defined props that are passed from the parent component and represent:

  • room: Room entry object (from BCMS).

  • createdAt: When the room was booked.

  • expiresAt: When the booking expires.

  • totalPrice: Price paid for the booking.

  • duration: Booking duration (in hours).

2. Then there is a function that checks whether the booking has expired.

3. Also there are functions that format the booking and expiration times for display and show the relative time until expiry.

4. Finally, render the UI of the booked room cards to display the room title, description, and booking date and extra info such as the capacity, duration, amount paid, and expiration date or an Expired text.

Below is an image showing how the booked room cards will look for rooms that have currently been booked and those whose bookings have expired.

booked rooms cards

API Endpoint for Booked Rooms

We’ll create another API route that fetches all bookings that have been created from the BCMS backend. This will be a simple API route that returns a list of bookings stored in BCMS and handles both success and error responses.

In your app directory, create an api folder, then a bookings folder and finally, a route.ts file in it. Paste these lines of code in the route file:

// app/api/bookings/route.ts
import { NextResponse } from "next/server";
import { bcms } from "@/lib/bcms";
import { BookingsEntry } from "@/bcms/types/ts";
export async function GET() {
  try {
    // Fetch all bookings from BCMS
    const bookings = (await bcms.entry.getAll("bookings")) as BookingsEntry[];
    // Return the bookings array
    return NextResponse.json(bookings);
  } catch (error) {
    console.error("Error fetching bookings:", error);
    return NextResponse.json(
      { success: false, error: "Failed to fetch bookings" },
      { status: 500 }
    );
  }
}

Code explanation:

  • NextResponse is used to return JSON responses with status codes in Next.js App Router API routes.

  • Also import the configured instance of the BCMS SDK client from the local file lib/bcms.ts and the type definition from the bookings entries.

  • The async function defines what happens when a GET request is made to /api/bookings, which is to use the bcms client to fetch all entries of type "bookings" and cast them to the BookingsEntry[]type.

  • Next, send the bookings array as a JSON response. If anything goes wrong during fetching, it logs the error and returns a 500 status code with an error message.

Not Found Page

Ever visited a site, navigated to an expired or unavailable route, and gotten a “Page Not Found” message? That’s what we’ll do next.

We’ll create a simple component for a page that will show up when a user visits a route that doesn't exist in the room booking application. Create a not-found.tsx file in the root of your app folder and paste these lines of code:

// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
  return (
    <div>
      <h2>Not Found</h2>
      <p>Could not find requested resource</p>
      <Link href="/">Return Home</Link>
    </div>
  );
}

The above code defines a custom 404 Not Found page in a Next.js app, including a link that lets the user go back to the homepage.

Now, if you navigate to a link that’s not included in this web application, you’ll get a simple page as shown in the image below:

Not Found Page

Style the page as desired. To clear up any confusion, here’s the folder structure for the app folder:

app/
┣ (auth)/
┃ ┣ sign-in/
┃ ┃ ┗ [[...sign-in]]/
┃ ┃ ┗ page.tsx
┃ ┗ sign-up/
┃ ┗ [[...sign-up]]/
┃ ┃ ┗ page.tsx
┣ (root)/
┃ ┣ booked/
┃ ┃ ┗ page.tsx
┃ ┣ rooms/
┃ ┃ ┣ [slug]/
┃ ┃ ┃ ┣ loading.tsx
┃ ┃ ┃ ┗ page.tsx
┃ ┃ ┗ page.tsx
┃ ┗ page.tsx
┣ api/
┃ ┣ bookings/
┃ ┃ ┗ route.ts
┃ ┗ create-booking/
┃ ┗ route.ts
┣ favicon.ico
┣ globals.css
┣ layout.tsx
┗ not-found.tsx

And here’s the folder structure for the src folder:

src/
┣ components/
┃ ┣ ui/
┃ ┃ ┣ badge.tsx
┃ ┃ ┣ button.tsx
┃ ┃ ┣ card.tsx
┃ ┃ ┣ dialog.tsx
┃ ┃ ┣ input.tsx
┃ ┃ ┣ label.tsx
┃ ┃ ┣ separator.tsx
┃ ┃ ┣ sheet.tsx
┃ ┃ ┣ sidebar.tsx
┃ ┃ ┣ skeleton.tsx
┃ ┃ ┣ sonner.tsx
┃ ┃ ┗ tooltip.tsx
┃ ┣ Header.tsx
┃ ┣ app-sidebar.tsx
┃ ┣ booked-room-card.tsx
┃ ┣ booking-modal.tsx
┃ ┗ room-card.tsx
┣ hooks/
┃ ┗ use-mobile.ts
┣ lib/
┃ ┣ bcms.ts
┃ ┗ utils.ts
┗ types/
┗ index.d.ts

Here’s a full demo of how the room booking software works:

room scheduling demo

Deploy your application on your preferred hosting platform and send your users a link so they can begin booking right away.

Try out the room scheduling app.

Conclusion: Build the best room scheduling software with BCMS

Building this room scheduling app with Next.js and BCMS shows how easy it is to manage content with a flexible headless CMS and how great it works when paired with modern frontend frameworks to build powerful web apps.

With BCMS handling the backend and Next.js powering the frontend, you get a flexible, efficient, and user-friendly scheduling system. This approach works great for booking systems of all kinds (hotel booking platform, coworking space scheduler, or any other reservations-based tool) and can be easily customized as your needs grow.

This is the GitHub repo for the project.

It takes a minute to start using BCMS

Gradient

Join our Newsletter

Get all the latest BCMS updates, news and events.

You’re in!

The first mail will be in your inbox next Monday!
Until then, let’s connect on Discord as well:

Join BCMS community on Discord

By submitting this form you consent to us emailing you occasionally about our products and services. You can unsubscribe from emails at any time, and we will never pass your email to third parties.

Gradient