
Managing Content on Small Websites: Guide for 2025 & Beyond
22 Nov 2022
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.
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.
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.
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.
In your components folder, create two files and paste the following lines of code inside their corresponding files.
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:
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:
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;
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.
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)
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
Inside your ‘Rooms’ template, create entries like this:
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
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.
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", }, }, };
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.
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.)
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.
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.
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.
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 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:
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:
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;
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.
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)
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.
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'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:
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.
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.
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:
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:
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.
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.
Get all the latest BCMS updates, news and events.
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.
There are many actionable insights in this blog post. Learn more: