content/tutorials/2.projects/build-an-hotel-booking-platform-with-next-js-stripe-and-directus-automate.md
In this tutorial, you will build a fully functional hotel booking website, using Next.js for the frontend, Directus as the backend service and Stripe for receiving payments.
You will create different room types, create and store rooms based on the room types, create new reservations dynamically when a room payment is successful and block the availability for that room based on the check-in and check-out date for the reservation.
You will need:
Before setting up Directus, let's understand how the different data models you will set up in Directus are related. You need 3 data collections - roomtypes, rooms, reservations.
room_types are the hotel's different types of rooms and can have multiple rooms.rooms are all the rooms the hotel has. A single room can only be of one room_types and can contain multiple reservations.reservations are all the reservations made by users from the application's frontend after successfully making a payment.In your Directus project, head to Settings -> Data Model to create these data models with the following fields:
room_types:
id: the primary field of this collectionname: A string field for a room typecapacity: An integer input field for the maximum number of guests a room can occupy.price: A string input price of a roomrooms: A One to Many relational field related to the rooms collection, signifying that a single room type can have multiple rooms.rooms:
id: the primary field of this collectionis_available: A toggle field for a room that indicates whether it is available for booking.capacity: An integer input field for the maximum number of guests a room can occupy.room_number: A string input to label a roomroom_type: A Many to One relational field related to the room_types collection, signifying that multiple rooms can only have a single room type.reservations: A One to Many relational field that is related to the reservations collection, signifying that a single room can have multiple reservationsreservations:
id: the primary field of this collectionfirst_name: A string input for the first namelast_name: A string input for the last nameemail: A string input for email addressphone_number: A string input for phone numbercheck_in_date: A Datetime input for the check-in date for the reservationcheck_out_date: A Datetime input for the check-out date for the reservationroom_id: A Many to One relational field that is related to the rooms collection, signifying that a single room can have multiple reservationsnights: A string input for the number of nights the reservations will last.total_price: A string input for the total cost of the reservationpayment_id: A string input for the payment for the reservation that happened on Stripe.Create some items in the Room Types and Rooms collections - here's some sample data.
Open the hotel-resort project in a code editor and create a .env.local file, being sure to place the value with your Directus project URL:
DIRECTUS_URL=YOUR_DIRECTUS_URL
APP_URL=http://localhost:3000
Create a new directory called lib. Inside it, create directus.ts to initialize a new Directus SDK instance:
import { createDirectus, rest } from "@directus/sdk";
type RoomTypes = {
capacity: number;
price: string;
name: string;
rooms: number[];
};
type Reservations = {
first_name: string;
last_name: string;
email: string;
phone_number: string;
check_in_date: string;
check_out_date: string;
room_id: string;
nights: number;
total_price: string;
payment_id: string
};
type Schema = {
room_types: RoomTypes[];
reservations: Reservations[];
};
const directus = createDirectus<Schema>(
process.env.DIRECTUS_URL as string
).with(rest());
export default directus;
In the lib directory, create a new file called apis.ts. This file will contain all the API requests the Next.js application will make to Directus.
Add a getRoomTypes function to lib/apis.ts:
import directus from "./directus";
import { readItems, createItem } from "@directus/sdk";
export const getRoomTypes = async (
checkInDate: string,
checkOutDate: string,
capacity: string
) => {
try {
const availableRooms = await directus.request(
readItems("room_types", {
// fetches all fields from room_types
fields: ["*"],
filter: {
// check if the capacity is greater than the requested capacity
capacity: { _gte: Number(capacity) },
},
deep: {
rooms: {
_limit: 1,
_filter: {
_or: [
// check if the room is available and if the check_in_date is empty
{
_and: [
{
is_available: true,
reservations: {
check_in_date: { _null: true },
},
},
],
},
// check if the room is available and if the check_out_date is empty
{
_and: [
{
is_available: true,
reservations: {
check_out_date: { _null: true },
},
},
],
},
// check if the check_out_date is less than the requested checkInDate
{
_and: [
{
is_available: true,
reservations: {
check_out_date: { _lt: checkInDate },
},
},
],
},
// check if the check_in_date is less than the requested checkOutDate
{
_and: [
{
is_available: true,
reservations: {
check_in_date: { _gt: checkOutDate },
},
},
],
},
],
},
},
},
})
);
return availableRooms;
} catch (error) {
console.error("Error fetching available room types:", error);
}
};
Let's break down the getRoomTypes object for better understanding:
filter field uses a filter operator to filter the room_types collection only to show the room types whose capacity is _gte (greater than or equal to) the requested capacity.deep parameter to filter the nested relational dataset in the room_types collection. In the deep parameter, we use Directus's logic operators _or and _and to check if any rooms are available and if their reservation date does not conflict with the requested data dates.reservations check_in_date and check_out_date dates are null, it means the room has never been booked before; thus, it is available. If the check_in_date of the room is _gt( greater than ) the checkOutDate, it means the room is also available, so also if the check_out_date is _lt (lesser than) the checkInDate._limit parameter, we are limiting the rooms result to be just one as we only need users to book one room when making a reservationgetRoomTypes function will either throw an error or return the fetched roomTypes.Create a another function for making a new reservation called makeReservation:
type ReservationData = {
first_name: string;
last_name: string;
email: string;
phone_number: string;
check_in_date: string;
check_out_date: string;
room_id: string;
nights: string;
total_price: string;
payment_id: string;
};
export const makeReservation = async (reservationData: ReservationData) => {
try {
const data = await directus.request(
createItem("reservations", {
...reservationData,
})
);
return "Booking Successful";
} catch (error) {
console.error("Error creating a reservation:", error);
}
};
The makeReservation function, sends a request to Directus to create a new reservation with a reservationData object.
Run the following command to initialize a Next.js project:
npx create-next-app@14 hotel-resort
During installation, when prompted, choose the following configurations:
✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? Yes
✔ Would you like to use Tailwind CSS? No
✔ Would you like to use `src/` directory? No
✔ Would you like to use App Router? (recommended) Yes
✔ Would you like to customize the default import alias (@/*)? Yes
✔ What import alias would you like configured? @/*
Install the required dependencies:
npm i @directus/sdk dayjs react-datepicker @stripe/stripe-js
In the app directory, create a form.tsx file with the content:
"use client";
import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
type DateState = Date | null;
export default function BookingForm() {
const [startDate, setStartDate] = useState<DateState>(new Date());
const [endDate, setEndDate] = useState<DateState>();
const router = useRouter();
const [error, setError] = useState("");
const handleChange = (range: DateState[]) => {
const [startDate, endDate] = range;
setStartDate(startDate);
setEndDate(endDate);
};
const handleFormSubmit = (e: FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
e.preventDefault()
if (!endDate) {
setError("Please add a check out date")
} else {
const checkAvailabilityData = {
checkInDate: startDate,
checkOutDate: endDate,
capacity: formData.get("capacity")
}
router.push(`/bookings/rooms?checkInDate=${checkAvailabilityData.checkInDate}&checkOutDate=${checkAvailabilityData.checkOutDate}&capacity=${checkAvailabilityData.capacity}`)
}
}
<input type="number" name="capacity" />;
return (
<form onSubmit={handleFormSubmit}>
<div>
<h2>Book an Hotel Room</h2>
<div>
<div>
<label htmlFor="checkInDate"> Check-in and Check-out Date:</label>
{error && <span>{ error }</span>}
<DatePicker
selected={startDate}
onChange={handleChange}
startDate={startDate}
endDate={endDate}
selectsRange
withPortal
required
/>
</div>
<div>
<label htmlFor="capacity"> Guest(s):</label>
<input
type="number"
name="capacity"
defaultValue={1}
min={1}
max={6}
/>
</div>
</div>
<button type="submit">Check Availability </button>
</div>
</form>
);
}
DatePicker component to render a date picker form for users to submit the selected range of their check-in and check-out periods.router.push method to the /bookings/rooms URL.To display this form, in the app/page.tsx file, replace the content with:
import BookingForm from "./form";
export default function Home() {
return (
<main>
<div>
<h1>Welcome to Next.js Hotel Resort</h1>
<p>Find the best hotel rooms and enjoy your stay</p>
<p>Home away from home</p>
<BookingForm />
</div>
</main>
);
}
Inside the app directory, create a new directory called bookings; inside it, create another directory called rooms. This route will be responsible for displaying the list of room types based on the availability of the rooms.
Inside of the rooms directory, create a page.tsx file:
import { getRoomTypes } from "@/lib/apis";
import dayjs from "dayjs";
import Link from "next/link";
export default async function Rooms({
searchParams,
}: {
searchParams: { [key: string]: string };
}) {
const { capacity, checkInDate, checkOutDate } = searchParams;
const formattedCheckInDate = dayjs(checkInDate).format("YYYY-MM-DD");
const formattedCheckOutDate = dayjs(checkOutDate).format("YYYY-MM-DD");
const roomTypes = await getRoomTypes(
formattedCheckInDate,
formattedCheckOutDate,
capacity
);
return (
<main>
<div>
<h1>Select a Room of your choice</h1>
<div>
{roomTypes &&
roomTypes.map((roomType) => {
return (
<div key={roomType.id}>
<h2>{roomType.name}</h2>
<p>capacity: {roomType.capacity}</p>
<p>Price per night: {roomType.price}</p>
{roomType.rooms.length > 0 ? (
<Link
href={`/bookings/checkout?checkInDate=${formattedCheckInDate}&checkOutDate=${formattedCheckOutDate}&room=${roomType.rooms[0]}&price=${roomType.price}&roomType=${roomType.name}`}
>
Book room
</Link>
) : (
"Room unavailable"
)}
</div>
);
})}
</div>
</div>
</main>
);
}
searchParams and runs the getRoomTypes function.getRoomTypes function and displays in HTMLroomTypes.rooms array to determine if a room is available and displays a link to book that room. If there's an available room, it renders a link to the booking form that room. Otherwise, it renders an Room unavailable text.Update your .env.local file with the publishable and secret keys you can find in your Stripe account:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=YOUR_STRIPE_PUBLISHABLE_KEY
STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY
In your app directory, create a new directory called api and, inside of it, create a directory stripe-session with a route.ts file (app/api/stripe-session/route.ts):
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
type RequestData = {
first_name: string,
last_name: string,
email: string,
phone_number: string,
check_in_date: string,
check_out_date: string,
room_id: string,
nights: number,
total_price: string,
roomType: string,
price: number;
};
export async function POST(req: Request) {
const {
price,
roomType,
room_id,
nights,
check_in_date,
check_out_date,
first_name,
last_name,
phone_number,
email,
}: RequestData = await req.json();
const totalPrice = price * 100;
try {
// Create Checkout Session
const stripeSession = await stripe.checkout.sessions.create({
line_items: [
{
quantity: 1,
price_data: {
currency: "usd",
product_data: {
name: roomType,
description: `Payment for ${nights} Night(s)`
},
unit_amount: totalPrice,
},
},
],
mode: "payment",
success_url: `${process.env.APP_URL}/bookings/success`,
cancel_url: `${process.env.APP_URL}/bookings/checkout?checkInDate=${check_in_date}&checkOutDate=${check_out_date}&roomType=${roomType}&price=${price/nights}&room=${room_id}`,
metadata: {
nights,
total_price: totalPrice,
room_id,
check_in_date,
check_out_date,
first_name,
last_name,
phone_number,
email,
},
});
console.log(stripeSession);
return NextResponse.json({ url: stripeSession.url! });
} catch (err) {
console.log({ err });
return NextResponse.json(
{ message: "An expected error occurred, please try again" },
{ status: 500 }
);
}
}
stripe.checkout.sessions.create method from the Stripe SDK.name, description and unit_amount.payment as the Stripe mode of payment.success_url and cancel_url URL to redirect the user to determine if the payment was successful or cancelled.metadata object to save the user details to the stripe payment payload when the payment is initiated.You will use this API route in the next section of this tutorial.
With an available room selected, you need to create a checkout page, get details, and pay for the available room. To do this, create a new directory in the app/bookings called checkout. Inside of it, make a roomWidget.tsx (app/bookings/checkout/roomWidget.tsx):
type RoomType = {
roomType: string,
checkInDate: string,
checkOutDate: string,
nights: number,
totalPrice: number
}
export default function RoomWidget({
roomType,
checkInDate,
checkOutDate,
nights,
totalPrice,
}: RoomType) {
return (
<div>
<h3>{roomType}</h3>
<p>Check In Date: <span>{checkInDate}</span></p>
<p>Check Out Date: <span>{checkOutDate}</span></p>
<p>Total Cost: {totalPrice}</p>
<p>{ nights } Night (s)</p>
</div>
)
}
This widget component will display a summary of the selected room on the checkout page.
Next, create a form.tsx file to gather details from the user input with the content:
"use client";
import { loadStripe } from "@stripe/stripe-js";
import { FormEvent, useEffect } from "react";
type RoomType = {
roomID: string;
nights: number;
checkInDate: string;
checkOutDate: string;
price: number;
roomType: string,
};
export default function BookingForm({
roomID,
nights,
price,
checkInDate,
checkOutDate,
roomType
}: RoomType) {
useEffect(() => {
loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}, []);
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const bookingData = {
check_in_date: checkInDate,
check_out_date: checkOutDate,
nights,
room_id: roomID,
price,
roomType,
first_name: formData.get("firstName") as string,
last_name: formData.get("lastName") as string,
email: formData.get("email") as string,
phone_number: formData.get("phoneNumber") as string,
};
try {
const response = await fetch("/api/stripe-session", {
method: "POST",
body: JSON.stringify(bookingData),
});
if (response.ok) {
const payment = await response.json();
window.location.href = payment.url;
} else {
console.error("Error submitting form:", response.statusText);
}
} catch (error) {
console.error("Error:", error);
}
};
return (
<form onSubmit={handleFormSubmit}>
<div>
<label htmlFor="firstName">First Name</label>
<input type="text" name="firstName" id="firstName" required />
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input type="text" name="lastName" id="lastName" required />
</div>
<div>
<label htmlFor="email">Email Address</label>
<input type="email" name="email" id="email" required />
</div>
<div>
<label htmlFor="phoneNumber">Phone Number</label>
<input type="text" name="phoneNumber" id="phoneNumber" required />
</div>
<div>
<button type="submit">Book Room</button>
</div>
</form>
);
}
The BookingForm component:
loadStripe function.firstName, lastName, email and phoneNumberpropsbookingData object, combining the selected room details and the user-filled form data./api/stripe-session route you created in the previous step to trigger a payment from Stripe.To render this form in the same directory, create a new file called page.tsx with the content:
import dayjs from "dayjs";
import BookingForm from "./form";
import RoomWidget from "./roomWidget";
export default async function Bookings({
searchParams,
}: {
searchParams: { [key: string]: string };
}) {
const { checkInDate, checkOutDate, room, price, roomType } = searchParams;
// Calculate the number of nights
const nights = dayjs(checkOutDate).diff(checkInDate, "day");
const totalPrice = nights * Number(price);
return (
<main>
<div>
<RoomWidget
roomType={roomType}
checkInDate={checkInDate}
checkOutDate={checkOutDate}
totalPrice={totalPrice}
nights={nights}
/>
<BookingForm
roomID={room}
nights={nights}
price={totalPrice}
checkInDate={checkInDate}
checkOutDate={checkOutDate}
roomType={roomType}
/>
</div>
</main>
);
}
Clicking on the Book Room button to submit the form will trigger a Stripe payment and take you to a Stripe checkout:
This form will trigger a stripe payment to pay for the selected room.
Next, create a success directory in the app/bookings directory to create a success page when payment is successfully made.
In the success directory, create a page.tsx that has the content:
"use client";
import Link from "next/link";
export default function SuccessPage() {
return (
<div>
<h1>Hotel Booking Payment Successful!</h1>
<p>You will receive an email with your booking details</p>
<Link href="/"
>
Go back to Homepage
</Link>
</div>
);
}
When a Stripe payment is resolved successfully, we want to send Directus a request to create a new reservation using the metadata we stored in the payment request.
To do this, Stripe has a list of events that we can listen to when a payment is triggered. Let's create a webhook in Next.js that can listen to a Stripe event when a payment goes through.
In your app/api/ directory, create a new directory with a subdirectory called webhook/stripe, and inside of this directory, create a route.ts for implementing a webhook,
Add the following code to route.ts:
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { makeReservation } from "@/lib/apis";
const checkout_session_completed = "checkout.session.completed";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
export async function POST(req: Request, res: Response) {
const reqBody = await req.text();
const sig = req.headers.get("stripe-signature");
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
if (!sig || !webhookSecret) return;
event = stripe.webhooks.constructEvent(reqBody, sig, webhookSecret);
} catch (error: any) {
console.log(error);
return NextResponse.json(
{ message: `Webhook Error: ${error.message}` },
{ status: 500 }
);
}
// load our event
switch (event.type) {
case checkout_session_completed:
const session = event.data.object;
if (!session.metadata || !session.payment_intent) {
console.error("Missing metadata or Payment Intent in Stripe session");
// Optionally return an error response
return NextResponse.json(
{ message: "Incomplete reservation data" },
{ status: 400 }
);
}
const {
// @ts-ignore
metadata: {
first_name,
last_name,
email,
phone_number,
check_in_date,
check_out_date,
room_id,
nights,
total_price,
},
payment_intent,
} = session;
console.log({ payment_intent });
await makeReservation({
first_name,
last_name,
email,
phone_number,
check_in_date,
check_out_date,
room_id,
nights,
total_price,
payment_id: payment_intent as string,
});
return NextResponse.json("Booking successful", {
status: 200,
statusText: "Booking Successful",
});
default:
console.log(`Unhandled event type ${event.type}`);
}
return NextResponse.json({ message: "Event Received" }, { status: 200 });
}
Let's break down the webhook route handler for better understanding:
STRIPE_SECRET_KEY.STRIPE_WEBHOOK_SECRET to check the authenticity of the request and create the Stripe event.event.type is checkout.session.completed (This means the payment checkout went through successfully in Stripe)metadata and payment_intent from the session that the event.data.object provides.metadata, the webhook requests Directus to create a new reservation.With Stripe CLI installed on your computer, run the command:
stripe login
Forward the API route handle in the Next.js application to Stripe to listen for it with the command:
stripe listen --forward-to localhost:3000/api/webhook/stripe
This will provide you with a response similar to this:
> Ready! You are using Stripe API Version [2022-08-01]. Your webhook signing secret is whsec_f9e4axxxxxxx (^C to quit)
Copy your webhook signing secret, as this is needed to verify and trigger an event from the webhook.
After that, open a new terminal tab and test the webhook by triggering a Stripe event from the CLI with:
stripe trigger payment_intent.succeeded
You will receive a response that looks like this:
In this tutorial, you've successfully created a hotel booking website fetching data from Directus and its relational datasets using Directus's powerful operators and filter rules, trigger a payment on Stripe, create a webhook that listens to the Stripe payment, and then make a reservation in Directus.
The complete code for this tutorial can be found here.
Some possible steps to carry out next might include: