[Full-stack]
RentHouse
A full-stack Airbnb-style house rental platform built with Next.js 14, Prisma, MongoDB, NextAuth, Google Maps, and Cloudinary.
A full-stack house rental platform where users can browse listings on an interactive Google Maps view, make date-range reservations, save favorites, and list their own properties. An admin dashboard lets privileged users manage listings, view all reservations, and promote or demote accounts — all protected by role-based access at both the UI and API level.
Project Structure
The app uses the Next.js 14 App Router with a clean server/client split. Server Components handle all initial data fetching directly — no loading states on first render, no client-side API calls to hydrate the page. Client Components take over for interactivity. The root layout mounts all modals and the navbar once globally, so no page needs to import them:
app/
├── actions/ # Server-only data fetching (getCurrentUser, getListings, …)
├── api/ # Route Handlers (auth, listings, reservations, favorites, search, admin)
├── components/ # UI — navbar, modals, map, dashboard, listing cards
├── hooks/ # Zustand stores (modal open/close state)
├── libs/ # Prisma client singleton
├── types/ # Safe serializable types (Date → string)
├── dashboard/ # Admin-only page
├── listings/[id]/ # Dynamic listing detail page
├── reservations/ # User reservations page
├── favorites/ # User favorites page
└── search/ # City search results page
The root layout runs getCurrentUser() on the server and passes the result down
to the Navbar and all three modals — they share the same user context without any
prop drilling through pages:
// app/layout.tsx
export default async function function RootLayout({ children }: {
children: any;
}): Promise<JSX.Element>
RootLayout({ children: anychildren }) {
const const currentUser: anycurrentUser = await getCurrentUser();
return (
<JSX.IntrinsicElements.html: DetailedHTMLProps<HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>html HTMLAttributes<HTMLHtmlElement>.lang?: string | undefinedlang="en" HTMLAttributes<HTMLHtmlElement>.className?: string | undefinedclassName="light">
<JSX.IntrinsicElements.body: DetailedHTMLProps<HTMLAttributes<HTMLBodyElement>, HTMLBodyElement>body HTMLAttributes<T>.className?: string | undefinedclassName={inter.className}>
<Providers>
<ToastProvider />
<Navbar currentUser: anycurrentUser={const currentUser: anycurrentUser} />
<RegisterModal />
<LoginModal />
<RentModal />
{children: anychildren}
<Footer />
</Providers>
</JSX.IntrinsicElements.body: DetailedHTMLProps<HTMLAttributes<HTMLBodyElement>, HTMLBodyElement>body>
</JSX.IntrinsicElements.html: DetailedHTMLProps<HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>html>
);
}
Database Schema
The schema is MongoDB via Prisma. Three main models cover the full domain.
User has a role enum for RBAC and a favoriteIds array — favorites are stored
as an array of listing IDs directly on the user document rather than a join table,
which means checking or toggling a favorite requires no JOIN:
// prisma/schema.prisma
enum UserRole {
ADMIN
USER
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String?
email String? @unique
emailVerified DateTime?
image String?
hashedPassword String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
favoriteIds String[] @db.ObjectId // array of listing IDs on the user doc
role UserRole @default(USER)
accounts Account[]
reservations Reservation[]
}
model Listing {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
description String
imageSrc String
createdAt DateTime @default(now())
roomCount Int
bathroomCount Int
lat Float
lng Float // coordinates stored for Google Maps pin placement
price Int
city String
country String
reservations Reservation[]
}
model Reservation {
id String @id @default(auto()) @map("_id") @db.ObjectId
userId String @db.ObjectId
listingId String @db.ObjectId
startDate DateTime
endDate DateTime
totalPrice Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
}
Safe Types — Serialization at the Boundary
Prisma returns Date objects. Next.js cannot pass Date objects from Server
Components to Client Components through props — they silently serialize to strings
at runtime, but TypeScript doesn't know that and types them as Date. The
types/index.ts file defines Safe* variants that replace every Date field
with string, making the type system match reality:
// app/types/index.ts
export type type SafeListing = Omit<Listing, "createdAt"> & {
createdAt: string;
}
SafeListing = type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }Construct a type with the properties of T except for those in type K.Omit<type Listing = /*unresolved*/ anyListing, 'createdAt'> & {
createdAt: stringcreatedAt: string;
};
export type type SafeReservation = Omit<Reservation, "createdAt" | "startDate" | "endDate" | "listing"> & {
createdAt: string;
startDate: string;
endDate: string;
listing: SafeListing;
}
SafeReservation = type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }Construct a type with the properties of T except for those in type K.Omit<
type Reservation = /*unresolved*/ anyReservation,
'createdAt' | 'startDate' | 'endDate' | 'listing'
> & {
createdAt: stringcreatedAt: string;
startDate: stringstartDate: string;
endDate: stringendDate: string;
listing: SafeListinglisting: type SafeListing = Omit<Listing, "createdAt"> & {
createdAt: string;
}
SafeListing;
};
export type type SafeUser = Omit<User, "createdAt" | "updatedAt" | "emailVerified"> & {
createdAt: string;
updatedAt: string;
emailVerified: string | null;
}
SafeUser = type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }Construct a type with the properties of T except for those in type K.Omit<type User = /*unresolved*/ anyUser, 'createdAt' | 'updatedAt' | 'emailVerified'> & {
createdAt: stringcreatedAt: string;
updatedAt: stringupdatedAt: string;
emailVerified: string | nullemailVerified: string | null;
};
Every server action converts dates before returning. getCurrentUser is the
most-called action in the app — it runs on every server-rendered page that needs
auth context:
// app/actions/getCurrentUser.ts
export default async function function getCurrentUser(): Promise<any>getCurrentUser() {
try {
const const session: anysession = await getServerSession(authOptions);
if (!const session: anysession?.user?.email) return null;
const const currentUser: anycurrentUser = await prisma.user.findUnique({
where: {
email: string;
}
where: { email: stringemail: const session: anysession.user.email as string },
});
if (!const currentUser: anycurrentUser) return null;
return {
...const currentUser: anycurrentUser,
createdAt: anycreatedAt: const currentUser: anycurrentUser.createdAt.toISOString(),
updatedAt: anyupdatedAt: const currentUser: anycurrentUser.updatedAt.toISOString(),
emailVerified: anyemailVerified: const currentUser: anycurrentUser.emailVerified?.toISOString() || null,
};
} catch (function (local var) error: anyerror: any) {
return null; // never throw — missing auth is a normal state, not an error
}
}
Authentication — Three Providers, One Strategy
NextAuth is configured with GitHub, Google, and credentials (email/password).
Both OAuth providers use allowDangerousEmailAccountLinking: true so a user who
registers with email can later sign in with Google using the same address and land
on the same account rather than creating a duplicate. Sessions use JWT, not
database sessions, so there is no Session table in the schema:
// app/api/auth/[...nextauth]/options.ts
export const const authOptions: NextAuthOptionsauthOptions: type NextAuthOptions = /*unresolved*/ anyNextAuthOptions = {
adapter: anyadapter: PrismaAdapter(prisma),
providers: any[]providers: [
GithubProvider({
clientId: stringclientId: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedGITHUB_ID as string,
clientSecret: stringclientSecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedGITHUB_SECRET as string,
allowDangerousEmailAccountLinking: booleanallowDangerousEmailAccountLinking: true,
}),
GoogleProvider({
clientId: stringclientId: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedGOOGLE_CLIENT_ID as string,
clientSecret: stringclientSecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedGOOGLE_CLIENT_SECRET as string,
allowDangerousEmailAccountLinking: booleanallowDangerousEmailAccountLinking: true,
}),
CredentialsProvider({
name: stringname: 'credentials',
credentials: {
email: {
label: string;
type: string;
};
password: {
label: string;
type: string;
};
}
credentials: {
email: {
label: string;
type: string;
}
email: { label: stringlabel: 'email', type: stringtype: 'text' },
password: {
label: string;
type: string;
}
password: { label: stringlabel: 'password', type: stringtype: 'password' },
},
async function authorize(credentials: any): Promise<any>authorize(credentials: anycredentials) {
if (!credentials: anycredentials?.email || !credentials: anycredentials?.password) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('Invalid credentials');
const const user: anyuser = await prisma.user.findUnique({
where: {
email: any;
}
where: { email: anyemail: credentials: anycredentials.email },
});
if (!const user: anyuser || !const user: anyuser?.hashedPassword) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('Invalid credentials');
const const isCorrect: anyisCorrect = await bcrypt.compare(credentials: anycredentials.password, const user: anyuser.hashedPassword);
if (!const isCorrect: anyisCorrect) throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('Invalid credentials');
return const user: anyuser;
},
}),
],
pages: {
signIn: string;
}
pages: { signIn: stringsignIn: '/' }, // redirect to home instead of NextAuth's default page
debug: booleandebug: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV === 'development',
session: {
strategy: string;
}
session: { strategy: stringstrategy: 'jwt' },
secret: string | undefinedsecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNEXTAUTH_SECRET,
};
The auth handler is a single export that covers both GET and POST — NextAuth's catch-all route convention:
// app/api/auth/[...nextauth]/route.ts
const const handler: anyhandler = NextAuth(authOptions);
export { const handler: anyhandler as const GET: any
export GET
GET, const handler: anyhandler as const POST: any
export POST
POST };
Registration hashes passwords with bcrypt at 12 rounds before any database write. After a successful registration the frontend closes the register modal and opens the login modal, so the user flows directly into sign-in:
// app/api/register/route.ts
const const hashedPassword: anyhashedPassword = await bcrypt.hash(password, 12);
const const user: anyuser = await prisma.user.create({
data: {
email: any;
name: void;
hashedPassword: any;
}
data: { email: anyemail, name: voidname, hashedPassword: anyhashedPassword },
});
return NextResponse.json(const user: anyuser);
Modal State Management with Zustand
Three modals (login, register, rent/list a property) are controlled by Zustand
stores. Each store is a single boolean with onOpen and onClose — no prop
drilling, no React context, no lifting state up:
// app/hooks/LoginModalStore.ts
const const useLoginModalStore: anyuseLoginModalStore = create<type LoginModalStore = /*unresolved*/ anyLoginModalStore>((set: anyset) => ({
isOpen: booleanisOpen: false,
onOpen: () => anyonOpen: () => set: anyset({ isOpen: booleanisOpen: true }),
onClose: () => anyonClose: () => set: anyset({ isOpen: booleanisOpen: false }),
}));
// app/hooks/RegisterModalStore.ts
const const useRegisterModalStore: anyuseRegisterModalStore = create<type RegisterModalStore = /*unresolved*/ anyRegisterModalStore>((set: anyset) => ({
isOpen: booleanisOpen: false,
onOpen: () => anyonOpen: () => set: anyset({ isOpen: booleanisOpen: true }),
onClose: () => anyonClose: () => set: anyset({ isOpen: booleanisOpen: false }),
}));
// app/hooks/RentModalStore.ts
const const useRentModalStore: anyuseRentModalStore = create<type RentModalStore = /*unresolved*/ anyRentModalStore>((set: anyset) => ({
isOpen: booleanisOpen: false,
onOpen: () => anyonOpen: () => set: anyset({ isOpen: booleanisOpen: true }),
onClose: () => anyonClose: () => set: anyset({ isOpen: booleanisOpen: false }),
}));
Any component anywhere in the tree can open a modal with one line. The
useFavorite hook uses this pattern as a guard — if the user is not logged in
and tries to favorite a listing, it opens the login modal instead of throwing an
error or silently failing:
// app/hooks/FavoriteStore.ts
const const toggleFavorite: anytoggleFavorite = useCallback(
async (e: MouseEvent<HTMLDivElement, MouseEvent>e: export namespace ReactReact.interface MouseEvent<T = Element, E = MouseEvent>MouseEvent<HTMLDivElement>) => {
e: MouseEvent<HTMLDivElement, MouseEvent>e.BaseSyntheticEvent<MouseEvent, EventTarget & HTMLDivElement, EventTarget>.stopPropagation(): voidstopPropagation();
if (!currentUser) {
return loginModal.onOpen(); // prompt auth, not an error
}
try {
if (hasFavorited) {
await axios.delete(`/api/favorites/${listingId}`);
toast('Deleted from favorites!', { icon: stringicon: '🗑️' });
} else {
await axios.post(`/api/favorites/${listingId}`);
toast('Added to Favorites', { icon: stringicon: '👏' });
}
router.refresh(); // re-run server components to reflect updated favoriteIds
} catch (function (local var) error: unknownerror) {
toast.error('Something went wrong.');
}
},
[currentUser, hasFavorited, listingId, loginModal, router],
);
hasFavorited is derived with useMemo directly from the user's favoriteIds
array — no extra API call, no useEffect, no state:
const const hasFavorited: anyhasFavorited = useMemo(() => {
const const list: anylist = currentUser?.favoriteIds || [];
return const list: anylist.includes(listingId);
}, [currentUser, listingId]);
Server Actions for Data Fetching
Pages fetch data through server actions — plain async functions that run on the
server, called directly from page components with no fetch, no useEffect, no
client-side loading state on first render.
The home page calls two actions and passes everything straight to the components:
// app/page.tsx
export default async function function Home(): Promise<JSX.Element>Home() {
const const listings: anylistings = await getListings();
const const currentUser: anycurrentUser = await getCurrentUser();
return (
<JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main HTMLAttributes<HTMLElement>.className?: string | undefinedclassName="min-h-screen">
<Container>
<HomeMap listings: anylistings={const listings: anylistings} />
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 ...">
{const listings: anylistings.map((listing: anylisting) => (
<ListingCard key: anykey={listing: anylisting.id} data: anydata={listing: anylisting} currentUser: anycurrentUser={const currentUser: anycurrentUser} />
))}
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
</Container>
</JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main>
);
}
The reservations action accepts optional filters — listingId for a single
listing's bookings, userId for a user's own reservations. The listing detail
page uses listingId; the user reservations page uses userId:
// app/actions/getReservations.ts
export default async function function getReservations(params: IParams): Promise<any>getReservations(params: IParamsparams: type IParams = /*unresolved*/ anyIParams) {
const { const listingId: IParamslistingId, const userId: IParamsuserId } = params: IParamsparams;
const const query: anyquery: any = {};
if (const listingId: IParamslistingId) const query: anyquery.listingId = const listingId: anylistingId;
if (const userId: IParamsuserId) const query: anyquery.userId = const userId: anyuserId;
const const reservations: anyreservations = await prisma.reservation.findMany({
where: anywhere: const query: anyquery,
include: {
listing: boolean;
}
include: { listing: booleanlisting: true }, // JOIN listings in a single query
orderBy: {
createdAt: string;
}
orderBy: { createdAt: stringcreatedAt: 'desc' },
});
return const reservations: anyreservations.map((r: anyr) => ({
...r: anyr,
createdAt: anycreatedAt: r: anyr.createdAt.toISOString(),
startDate: anystartDate: r: anyr.startDate.toISOString(),
endDate: anyendDate: r: anyr.endDate.toISOString(),
listing: anylisting: {
...r: anyr.listing,
createdAt: anycreatedAt: r: anyr.listing.createdAt.toISOString(),
},
}));
}
getFavorites queries listings whose IDs appear in the user's favoriteIds array
— a single findMany with an in filter, no join table:
// app/actions/getFavorites.ts
const const favorites: anyfavorites = await prisma.listing.findMany({
where: {
id: {
in: any[];
};
}
where: {
id: {
in: any[];
}
id: { in: any[]in: [...(currentUser.favoriteIds || [])] },
},
});
The listing detail page composes three actions and passes everything to the client
House component:
// app/listings/[listingId]/page.tsx
const const page: ({ params }: {
params: IParams;
}) => Promise<JSX.Element>
page = async ({ params: IParamsparams }: { params: IParamsparams: type IParams = /*unresolved*/ anyIParams }) => {
const const listing: anylisting = await getListingById(params: IParamsparams);
const const currentUser: anycurrentUser = await getCurrentUser();
const const reservations: anyreservations = await getReservations(params: IParamsparams);
if (!const listing: anylisting) return <ErrorState />;
return <House listing: anylisting={const listing: anylisting} currentUser: anycurrentUser={const currentUser: anycurrentUser} reservations: anyreservations={const reservations: anyreservations} />;
};
API Routes
Favorites — Add and Remove
POST adds a listing ID to the user's favoriteIds array; DELETE removes it with
filter. Both read the current array first, mutate it in memory, then write back
in a single update:
// app/api/favorites/[listingId]/route.ts
export async function function POST(request: Request, { params }: {
params: IParams;
}): Promise<any>
POST(request: Requestrequest: Request, { params: IParamsparams }: { params: IParamsparams: type IParams = /*unresolved*/ anyIParams }) {
const const currentUser: anycurrentUser = await getCurrentUser();
if (!const currentUser: anycurrentUser) return NextResponse.error();
const { const listingId: IParamslistingId } = params: IParamsparams;
if (!const listingId: IParamslistingId || typeof const listingId: anylistingId !== 'string') throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('Invalid ID');
let let favoriteIds: any[]favoriteIds = [...(const currentUser: anycurrentUser.favoriteIds || [])];
let favoriteIds: any[]favoriteIds.Array<any>.push(...items: any[]): numberAppends new elements to the end of an array, and returns the new length of the array.push(const listingId: stringlistingId);
const const user: anyuser = await prisma.user.update({
where: {
id: any;
}
where: { id: anyid: const currentUser: anycurrentUser.id },
data: {
favoriteIds: any[];
}
data: { favoriteIds: any[]favoriteIds },
});
return NextResponse.json(const user: anyuser);
}
export async function function DELETE(request: Request, { params }: {
params: IParams;
}): Promise<any>
DELETE(request: Requestrequest: Request, { params: IParamsparams }: { params: IParamsparams: type IParams = /*unresolved*/ anyIParams }) {
const const currentUser: anycurrentUser = await getCurrentUser();
if (!const currentUser: anycurrentUser) return NextResponse.error();
const { const listingId: IParamslistingId } = params: IParamsparams;
let let favoriteIds: any[]favoriteIds = [...(const currentUser: anycurrentUser.favoriteIds || [])];
let favoriteIds: any[]favoriteIds = let favoriteIds: any[]favoriteIds.Array<any>.filter(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): any[] (+1 overload)Returns the elements of an array that meet the condition specified in a callback function.filter((id: anyid) => id: anyid !== const listingId: IParamslistingId);
const const user: anyuser = await prisma.user.update({
where: {
id: any;
}
where: { id: anyid: const currentUser: anycurrentUser.id },
data: {
favoriteIds: any[];
}
data: { favoriteIds: any[]favoriteIds },
});
return NextResponse.json(const user: anyuser);
}
Reservations — Nested Write and Deletion
Creating a reservation uses Prisma's nested create inside an update — the
reservation is created and linked to its listing in a single database operation:
// app/api/reservations/route.ts
const const listingAndReservation: anylistingAndReservation = await prisma.listing.update({
where: {
id: any;
}
where: { id: anyid: listingId },
data: {
reservations: {
create: {
userId: any;
startDate: any;
endDate: any;
totalPrice: any;
};
};
}
data: {
reservations: {
create: {
userId: any;
startDate: any;
endDate: any;
totalPrice: any;
};
}
reservations: {
create: {
userId: any;
startDate: any;
endDate: any;
totalPrice: any;
}
create: {
userId: anyuserId: currentUser.id,
startDate: anystartDate,
endDate: anyendDate,
totalPrice: anytotalPrice,
},
},
},
});
Cancellation is a plain deleteMany on the reservation ID:
// app/api/reservations/[reservationId]/route.ts
const const reservation: anyreservation = await prisma.reservation.deleteMany({
where: {
id: any;
}
where: { id: anyid: reservationId },
});
Listings — Create and Delete
The POST handler parses price as an int and coordinates as floats explicitly —
HTML inputs are always strings, and Prisma's schema expects Int and Float:
// app/api/listings/route.ts
const const listing: anylisting = await prisma.listing.create({
data: {
title: any;
description: any;
imageSrc: any;
bathroomCount: any;
roomCount: any;
city: any;
country: any;
price: number;
lat: number;
lng: number;
}
data: {
title: anytitle,
description: anydescription,
imageSrc: anyimageSrc,
bathroomCount: anybathroomCount,
roomCount: anyroomCount,
city: anycity,
country: anycountry,
price: numberprice: function parseInt(string: string, radix?: number): numberConverts a string to an integer.parseInt(price, 10),
lat: numberlat: function parseFloat(string: string): numberConverts a string to a floating-point number.parseFloat(lat),
lng: numberlng: function parseFloat(string: string): numberConverts a string to a floating-point number.parseFloat(lng),
},
});
Admin Role Toggle
The admin route reads the current role from the database and flips it server-side — no role string sent in the request body that a client could spoof:
// app/api/admin/route.ts
export async function function POST(request: Request): Promise<any>POST(request: Requestrequest: Request) {
const const body: anybody = await request: Requestrequest.Body.json(): Promise<any>[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)json();
const const userId: anyuserId = const body: anybody.id;
const const user: anyuser = await prisma.user.findUnique({ where: {
id: any;
}
where: { id: anyid: const userId: anyuserId } });
// toggle happens on the server — client sends only the user ID
const const newRole: "ADMIN" | "USER"newRole = const user: anyuser?.role === 'ADMIN' ? 'USER' : 'ADMIN';
await prisma.user.updateMany({
where: {
id: any;
}
where: { id: anyid: const userId: anyuserId },
data: {
role: string;
}
data: { role: stringrole: const newRole: "ADMIN" | "USER"newRole },
});
return NextResponse.json({ newRole: stringnewRole });
}
City Search
The search route is a GET handler that reads a q query parameter and queries
listings by city. The /search page is a Client Component that calls this
endpoint via axios when the query param changes — this page stays client-side so
the URL updates reactively as the user navigates:
// app/api/search/route.ts
export async function function GET(req: NextRequest): Promise<Response>GET(req: NextRequestreq: type NextRequest = /*unresolved*/ anyNextRequest) {
const const query: anyquery = req: NextRequestreq.nextUrl.searchParams.get('q');
const const listings: anylistings = await prisma.listing.findMany({
where: {
city: string;
}
where: { city: stringcity: const query: anyquery?.toLocaleLowerCase() as string },
});
return var Response: {
new (body?: BodyInit | null, init?: ResponseInit): Response;
prototype: Response;
error(): Response;
json(data: any, init?: ResponseInit): Response;
redirect(url: string | URL, status?: number): Response;
}
The **`Response`** interface of the Fetch API represents the response to a request.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)Response.function json(data: any, init?: ResponseInit): ResponseThe **`json()`** static method of the Response interface returns a `Response` that contains the provided JSON data as body, and a Content-Type header which is set to `application/json`.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/json_static)json(
const listings: anylistings.map((l: anyl) => ({
...l: anyl,
createdAt: anycreatedAt: l: anyl.createdAt.toString(),
})),
);
}
// app/components/Searched.tsx — client-side consumer
useEffect(() => {
const const fetchData: () => Promise<void>fetchData = async () => {
setLoading(true);
const const response: anyresponse = await axios.get(`/api/search?q=${encodedSearchQuery}`);
setData(const response: anyresponse.data);
setLoading(false);
};
setData(null);
if (encodedSearchQuery) const fetchData: () => Promise<void>fetchData();
}, [encodedSearchQuery]); // re-fetches whenever the URL query changes
Reservation Logic — Disabled Dates and Total Price
The House client component calculates which calendar dates should be disabled
(already booked) and the total price dynamically as the user selects a range.
Disabled dates are computed with useMemo from the existing reservations.
eachDayOfInterval from date-fns expands each reservation into every individual
day between start and end, which the calendar consumes directly:
// app/components/House.tsx
const const disabledDates: anydisabledDates = useMemo(() => {
let let dates: Date[]dates: Date[] = [];
reservations.forEach((reservation: anyreservation: any) => {
const const range: anyrange = eachDayOfInterval({
start: Datestart: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date(reservation: anyreservation.startDate),
end: Dateend: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date(reservation: anyreservation.endDate),
});
let dates: Date[]dates = [...let dates: Date[]dates, ...const range: anyrange];
});
return let dates: Date[]dates;
}, [reservations]);
Total price recalculates on every date range change. differenceInDays returns 0
if the same day is selected twice, so the fallback returns the base nightly price:
useEffect(() => {
if (dateRange.startDate && dateRange.endDate) {
const const dayCount: anydayCount = differenceInDays(dateRange.endDate, dateRange.startDate);
if (const dayCount: anydayCount && listing?.price) {
setTotalPrice(listing.price * const dayCount: anydayCount);
} else {
setTotalPrice(listing.price); // same-day selection = base price
}
}
}, [dateRange, listing?.price]);
Submitting a reservation checks auth first — unauthenticated users get the login modal instead of an error. On success it resets the date range and navigates to the reservations page:
const const onCreateReservation: anyonCreateReservation = useCallback(() => {
if (!currentUser) return loginModal.onOpen();
setIsLoading(true);
axios
.post('/api/reservations', {
totalPrice: anytotalPrice,
startDate: anystartDate: dateRange.startDate,
endDate: anyendDate: dateRange.endDate,
listingId: anylistingId: listing?.id,
})
.then(() => {
toast.success('Success');
setDateRange(initialDateRange);
router.push('/reservations');
})
.catch(() => toast.error('Error'))
.finally(() => setIsLoading(false));
}, [loginModal, totalPrice, dateRange, listing?.id, currentUser, router]);
Google Maps Integration
The Map component renders all listings as markers. Clicking a marker opens an
InfoWindowF showing the listing as a NextUI card — title, image, and price —
with a click handler that navigates to the detail page. A second default
InfoWindowF shows a house icon on every unselected marker so listings are visible
at a glance without requiring a click:
// app/components/Map.tsx
const const Map: FC<MapProps>Map: export namespace ReactReact.type FC<P = {}> = FunctionComponent<P>Represents the type of a function component. Can optionally
receive a type argument that represents the props the component
receives.FC<type MapProps = /*unresolved*/ anyMapProps> = ({ listings: MapPropslistings }) => {
const const center: anycenter = useMemo(() => ({ lat: numberlat: 31.501120166432887, lng: numberlng: -9.75375476694082 }), []);
const [const selected: anyselected, const setSelected: anysetSelected] = useState<number | null>(null);
const { const isLoaded: anyisLoaded } = useLoadScript({
googleMapsApiKey: stringgoogleMapsApiKey: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '',
});
if (!const isLoaded: anyisLoaded) {
return <Skeleton className: stringclassName="h-[500px] w-full rounded-lg" />;
}
return (
<GoogleMap mapContainerClassName: stringmapContainerClassName="h-[500px] w-full rounded-2xl" zoom: numberzoom={4.89} center: anycenter={const center: anycenter}>
{listings: MapPropslistings.map((listing: anylisting, index: anyindex) => (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div Attributes.key?: Key | null | undefinedkey={listing: anylisting.id}>
<MarkerF
position: {
lat: any;
lng: any;
}
position={{ lat: anylat: listing: anylisting.lat, lng: anylng: listing: anylisting.lng }}
onClick: () => anyonClick={() => const setSelected: anysetSelected(index: anyindex)}
/>
{/* Default: house icon on every unselected marker */}
{const selected: anyselected === null && (
<InfoWindowF position: {
lat: any;
lng: any;
}
position={{ lat: anylat: listing: anylisting.lat, lng: anylng: listing: anylisting.lng }}>
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div DOMAttributes<HTMLDivElement>.onClick?: MouseEventHandler<HTMLDivElement> | undefinedonClick={() => const setSelected: anysetSelected(index: anyindex)}>
<BsFillHouseFill size: numbersize={40} />
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
</InfoWindowF>
)}
{/* Selected: full card with navigation on click */}
{const selected: anyselected === index: anyindex && (
<InfoWindowF
position: {
lat: any;
lng: any;
}
position={{ lat: anylat: listing: anylisting.lat, lng: anylng: listing: anylisting.lng }}
onCloseClick: () => anyonCloseClick={() => const setSelected: anysetSelected(null)}
>
<Card isPressable: trueisPressable onClick: () => anyonClick={() => router.push(`/listings/${listing: anylisting.id}`)}>
<CardBody className: stringclassName="overflow-visible p-0">
<var Image: new (width?: number, height?: number) => HTMLImageElementImage
src: anysrc={listing: anylisting.imageSrc}
alt: anyalt={listing: anylisting.title}
className: stringclassName="h-[140px] w-full object-cover"
/>
</CardBody>
<CardFooter className: stringclassName="text-small justify-between">
<JSX.IntrinsicElements.b: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>b>{listing: anylisting.title}</JSX.IntrinsicElements.b: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>b>
<JSX.IntrinsicElements.p: DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p HTMLAttributes<HTMLParagraphElement>.className?: string | undefinedclassName="text-default-500">${listing: anylisting.price}</JSX.IntrinsicElements.p: DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
</CardFooter>
</Card>
</InfoWindowF>
)}
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
))}
</GoogleMap>
);
};
The map is wrapped in a HomeMap component with a NextUI toggle so users can show
or hide it without leaving the page:
// app/components/HomeMap.tsx
const const HomeMap: FC<MapProps>HomeMap: export namespace ReactReact.type FC<P = {}> = FunctionComponent<P>Represents the type of a function component. Can optionally
receive a type argument that represents the props the component
receives.FC<type MapProps = /*unresolved*/ anyMapProps> = ({ listings: MapPropslistings }) => {
const [const isSelected: anyisSelected, const setIsSelected: anysetIsSelected] = useState(true);
return (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="flex flex-col gap-3 pb-2">
<Switch isSelected: anyisSelected={const isSelected: anyisSelected} onValueChange: anyonValueChange={const setIsSelected: anysetIsSelected}>
Use map
</Switch>
{const isSelected: anyisSelected && (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="pb-10">
<var Map: MapConstructorMap listings: MapPropslistings={listings: MapPropslistings} />
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
)}
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
);
};
The same Map component is reused on the listing detail page with a single-element
array so only that listing's marker is shown.
Multi-Step Listing Form
The RentModal uses a local step state with a numeric enum to drive a five-step
form inside the modal shell. The modal's action label and secondary action update
automatically from step via useMemo — no conditional JSX in the render:
// app/components/modal/RentModal.tsx
enum enum STEPSSTEPS {
function (enum member) STEPS.LOCATION = 0LOCATION = 0,
function (enum member) STEPS.INFO = 1INFO = 1,
function (enum member) STEPS.IMAGES = 2IMAGES = 2,
function (enum member) STEPS.DESCRIPTION = 3DESCRIPTION = 3,
function (enum member) STEPS.PRICE = 4PRICE = 4,
}
const const actionLabel: anyactionLabel = useMemo(() => {
if (step === enum STEPSSTEPS.function (enum member) STEPS.PRICE = 4PRICE) return 'Add Home';
return 'NEXT';
}, [step]);
const const secondaryActionLabel: anysecondaryActionLabel = useMemo(() => {
if (step === enum STEPSSTEPS.function (enum member) STEPS.LOCATION = 0LOCATION) return var undefinedundefined; // no back button on first step
return 'BACK';
}, [step]);
const const onSubmit: SubmitHandler<FieldValues>onSubmit: type SubmitHandler = /*unresolved*/ anySubmitHandler<type FieldValues = /*unresolved*/ anyFieldValues> = async (data: anydata) => {
if (step !== enum STEPSSTEPS.function (enum member) STEPS.PRICE = 4PRICE) return onNext(); // advance step, don't submit yet
// only the final step actually posts to the API
await axios.post('/api/listings', data: anydata);
toast.success('Successfully added');
reset();
router.refresh();
setStep(enum STEPSSTEPS.function (enum member) STEPS.LOCATION = 0LOCATION);
};
Step 0 collects coordinates and location as text inputs. Latitude and longitude are
number type inputs — the listing creation route calls parseFloat on them before
the Prisma write:
<Input label: stringlabel="Lat" id: stringid="lat" type: stringtype="number" register: anyregister={register} errors: anyerrors={errors} required: truerequired />
<Input label: stringlabel="Lng" id: stringid="lng" type: stringtype="number" register: anyregister={register} errors: anyerrors={errors} required: truerequired />
<Input id: stringid="country" label: stringlabel="Country" register: anyregister={register} errors: anyerrors={errors} required: truerequired />
<Input id: stringid="city" label: stringlabel="City" register: anyregister={register} errors: anyerrors={errors} required: truerequired />
Image upload is handled by Cloudinary via next-cloudinary's CldUploadWidget.
The widget returns a secure URL on upload, which goes straight into React Hook Form
state via setCustomValue:
// app/components/inputs/ImagesUpload.tsx
const const handleUpload: anyhandleUpload = useCallback(
(result: anyresult: any) => {
onChange(result: anyresult.info.secure_url); // Cloudinary URL stored directly in form
},
[onChange],
);
return (
<CldUploadWidget onUpload: anyonUpload={const handleUpload: anyhandleUpload} uploadPreset: stringuploadPreset="j9centpr" options: {
maxFiles: number;
}
options={{ maxFiles: numbermaxFiles: 4 }}>
{({ open: anyopen }) => (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div DOMAttributes<HTMLDivElement>.onClick?: MouseEventHandler<HTMLDivElement> | undefinedonClick={() => open: anyopen?.()} HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="cursor-pointer border-2 border-dashed p-20 ...">
<BsCardImage size: numbersize={100} />
<JSX.IntrinsicElements.p: DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p HTMLAttributes<T>.className?: string | undefinedclassName="text-lg font-semibold text-gray-400">Click Here to Upload</JSX.IntrinsicElements.p: DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p>
{value && (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="absolute inset-0 h-full w-full">
<var Image: new (width?: number, height?: number) => HTMLImageElementImage alt: stringalt="upload" fill: truefill src: anysrc={value} />
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
)}
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
)}
</CldUploadWidget>
);
Admin Dashboard
The dashboard page runs a server-side role check before rendering anything. A user
who navigates to /dashboard without the ADMIN role sees a message and a link
home — no redirect, no client-side check, no flash of protected content:
// app/dashboard/page.tsx
export default async function function Home(): Promise<JSX.Element>Home() {
const const currentUser: anycurrentUser = await getCurrentUser();
const const listings: anylistings = await getListings();
const const users: anyusers = await getTotalUsers();
const const reservations: anyreservations = await getTotalReservations();
if (const currentUser: anycurrentUser?.role !== 'ADMIN') {
return (
<JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main HTMLAttributes<HTMLElement>.className?: string | undefinedclassName="flex min-h-screen flex-col items-center justify-center">
<Heading
title: stringtitle="You don't have access to this page"
subtitle: stringsubtitle="Please contact your admin"
center: truecenter
/>
<JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button HTMLAttributes<T>.className?: string | undefinedclassName="rounded-md bg-black px-6 py-3 text-white">
<JSX.IntrinsicElements.a: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>a AnchorHTMLAttributes<HTMLAnchorElement>.href?: string | undefinedhref="/">Back to home Page</JSX.IntrinsicElements.a: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>a>
</JSX.IntrinsicElements.button: DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
</JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main>
);
}
return (
<JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main>
<Dashboad
reservations: anyreservations={const reservations: anyreservations}
currentUser: anycurrentUser={const currentUser: anycurrentUser}
data: anydata={const listings: anylistings}
users: anyusers={const users: anyusers}
/>
</JSX.IntrinsicElements.main: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>main>
);
}
The Dashboard client component uses a CONTENT enum and local section state
to switch between three views — Users, Houses, and Reservations — driven by the
sidebar. The sidebar is position: fixed; a spacer div of matching width pushes
the main content over without margin-left:
// app/components/dashboard/Dashboad.tsx
export enum enum CONTENTCONTENT {
function (enum member) CONTENT.USER = 0USER = 0,
function (enum member) CONTENT.HOUSES = 1HOUSES = 1,
function (enum member) CONTENT.RESERVATIONS = 2RESERVATIONS = 2,
}
// Fixed sidebar
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="fixed top-0 bottom-0 h-screen w-[70px] bg-gray-100 py-2 sm:w-[150px] md:w-[250px]">
<SidebarContent label: stringlabel="Users" icon: anyicon={AiOutlineUser} onClick: () => anyonClick={() => setSection(enum CONTENTCONTENT.function (enum member) CONTENT.USER = 0USER)} />
<SidebarContent
label: stringlabel="Houses"
icon: anyicon={MdOutlineRealEstateAgent}
onClick: () => anyonClick={() => setSection(enum CONTENTCONTENT.function (enum member) CONTENT.HOUSES = 1HOUSES)}
/>
<SidebarContent
label: stringlabel="Reservations"
icon: anyicon={AiOutlineCalendar}
onClick: () => anyonClick={() => setSection(enum CONTENTCONTENT.function (enum member) CONTENT.RESERVATIONS = 2RESERVATIONS)}
/>
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>;
{
/* Spacer — matches the fixed sidebar width exactly */
}
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<HTMLDivElement>.className?: string | undefinedclassName="w-[70px] sm:w-[150px] md:w-[250px]" />;
The Dashcards component is the shared row for all three views. It accepts
optional props for each use case — Delete/houseId for Houses, update/userId
for Users, reservation/startDate/endDate for Reservations. The role toggle
button label derives from the current role so it always reads correctly without
any hardcoded strings at the call site:
// app/components/dashboard/Dashcards.tsx
{
update && (
<Button
label: stringlabel={role === 'ADMIN' ? 'Remove Admin' : 'Make Admin'}
onClick: () => anyonClick={() => Admin(userId || '')}
/>
);
}
Both the delete and admin-toggle actions call router.refresh() on success so
server components re-run and the updated data is visible without a full reload:
const const Admin: anyAdmin = useCallback(
(id: stringid: string) => {
axios
.post('/api/admin', { id: stringid })
.then(() => {
toast.success('Success');
router.refresh(); // re-run server component tree to pick up role change
})
.catch(() => toast.error('Something went wrong'));
},
[router],
);
The navbar conditionally renders the Dashboard link based on role. The check
happens on the server in layout.tsx — currentUser is already fetched there and
passed down, so there is no client-side role check and no extra request. The navbar
and footer also hide themselves on the /dashboard route since the dashboard has
its own fixed sidebar navigation:
// app/components/navbar/Navbar.tsx
const const pathname: anypathname = usePathname();
if (const pathname: anypathname === '/dashboard') return null;
// ...
{
currentUser?.role === 'ADMIN' ? (
<JSX.IntrinsicElements.a: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>a AnchorHTMLAttributes<HTMLAnchorElement>.href?: string | undefinedhref="/dashboard">
<MenuContent label: stringlabel="Dashboard" onClick: () => voidonClick={() => {}} />
</JSX.IntrinsicElements.a: DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>a>
) : null;
}
Prisma Client Singleton
In development, Next.js hot-reloads modules which would create a new PrismaClient
on every file save and exhaust the database connection pool. The singleton pattern
attaches the client to globalThis in development so it survives hot reloads
without creating additional connections:
// app/libs/prismadb.ts
declare global {
var var prisma: anyprisma: type PrismaClient = /*unresolved*/ anyPrismaClient | undefined;
}
const const client: anyclient = module globalThisglobalThis.var prisma: anyprisma || new PrismaClient();
if (var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV !== 'production') module globalThisglobalThis.var prisma: anyprisma = const client: anyclient;
export default const client: anyclient;
In production globalThis.prisma is never set, so a fresh client is created once
per cold start and reused for the lifetime of the process.