Skip to main content

RentHouse

Home/projects/RentHouse

[Full-stack]

RentHouse

A full-stack Airbnb-style house rental platform built with Next.js 14, Prisma, MongoDB, NextAuth, Google Maps, and Cloudinary.

Next.js 14TypeScriptPrismaMongoDBNextAuth.jsTailwind CSSZustandGoogle Maps APICloudinaryNextUI
View repository

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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedGITHUB_ID as string,
clientSecret: stringclientSecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedGITHUB_SECRET as string,
allowDangerousEmailAccountLinking: booleanallowDangerousEmailAccountLinking: true, }), GoogleProvider({ clientId: stringclientId: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedGOOGLE_CLIENT_ID as string,
clientSecret: stringclientSecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedNODE_ENV === 'development',
session: {
    strategy: string;
}
session
: { strategy: stringstrategy: 'jwt' },
secret: string | undefinedsecret: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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);

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[]): number
Appends new elements to the end of an array, and returns the new length of the array.
@paramitems New elements to add to 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.
@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
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): number
Converts a string to an integer.
@paramstring A string to convert into a number.@paramradix A value between 2 and 36 that specifies the base of the number in `string`. If this argument is not supplied, strings with a prefix of '0x' are considered hexadecimal. All other strings are considered decimal.
parseInt
(price, 10),
lat: numberlat: function parseFloat(string: string): number
Converts a string to a floating-point number.
@paramstring A string that contains a floating-point number.
parseFloat
(lat),
lng: numberlng: function parseFloat(string: string): number
Converts a string to a floating-point number.
@paramstring A string that contains 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 }); }

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): Response
The **`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.
@templateP The props the component accepts.@see{@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components React TypeScript Cheatsheet}@aliasfor {@link FunctionComponent}@example```tsx // With props: type Props = { name: string } const MyComponent: FC<Props> = (props) => { return <div>{props.name}</div> } ```@example```tsx // Without props: const MyComponentWithoutProps: FC = () => { return <div>MyComponentWithoutProps</div> } ```
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.
@templateP The props the component accepts.@see{@link https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/function_components React TypeScript Cheatsheet}@aliasfor {@link FunctionComponent}@example```tsx // With props: type Props = { name: string } const MyComponent: FC<Props> = (props) => { return <div>{props.name}</div> } ```@example```tsx // Without props: const MyComponentWithoutProps: FC = () => { return <div>MyComponentWithoutProps</div> } ```
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.tsxcurrentUser 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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.