Contents
- Introduction
- Tech Stack
- Quick Overview
- API
- Frontend
- Mobile App
- Admin Dashboard
- Points of Interest
- Resources
Source code:
Introduction
The idea emerged from a desire to build without boundaries – a fully customizable and operational property rental platform where every aspect is within your control:
Own the UI/UX: Design unique customer experiences without fighting against template limitations
Control the Backend: Implement custom business logic and data structures that perfectly match the requirements
Master DevOps: Deploy, scale, and monitor the application with preferred tools and workflows
Extend Freely: Add new features and integrations without platform constraints or additional fees
Tech Stack
Here's the tech stack that made it possible:
- TypeScript
- Node.js
- MongoDB
- React
- MUI
- Expo
- Stripe
- Docker
A key design decision was made to use TypeScript due to its numerous advantages. TypeScript offers strong typing, tooling, and integration, resulting in high-quality, scalable, more readable and maintainable code that is easy to debug and test.
I chose React for its powerful rendering capabilities, MongoDB for flexible data modeling, and Stripe for secure payment processing.
By choosing this stack, you're not just building a website and mobile app – you're investing in a foundation that can evolve with your needs, backed by robust open-source technologies and a growing developer community.
Quick overview
In this section, you'll see the main pages of the frontend, the admin dashboard and the mobile app.
Frontend
From the frontend, the customer can search for available properties, choose a property and checkout.
Below is the main page of the frontend where the customer can a location point and time, and search for available properties.
Below is the page where the customer can view the details of the property:
Below is the checkout page where the customer can set rental options and checkout. If the customer is not registered, he can checkout and register at the same time. He will receive a confirmation and activation email to set his password if he is not registered yet.
Below is the sign up page.
Below is the page where the customer can see a booking in detail.
Below is the page where the customer can manage his settings.
That's it. That's the main pages of the frontend.
Admin Dashboard
Three types of users:
- Admins: They have full access to the admin dashboard. They can do everything.
- Agencies: They have limited access on the admin dashboard. They can only manage their properties, bookings and customers.
- Customers: They have access to the frontend and the mobile app only. They cannot access the admin dashboard.
The platform is designed to work with multiple agencies. Each agency can manage its properties, customers and bookings from the admin dashboard. The platform can also work with only one agency as well.
From the backend, admins can create and manage agencies, properties, locations, customers and bookings.
When new agencies are created, they receive an email prompting them to create their account to access the admin dashboard so they can manage their properties, customers and bookings.
Below is the sign in page of the admin dashboard.
If the status of a booking changes, the related customer will receive a notification and an email.
Below is the page where properties are displayed and can be managed.
Below is the page where admins and agencies can edit properties.
Below is the page where to create bookings if the agency wants to create a booking from the admin dashboard. Otherwise, bookings are created automatically when the checkout process is completed from the frontend or the mobile app.
Below is the page where to manage agencies.
Below is the page where to edit agencies.
Below is the page where to see customer's bookings.
There are other pages but these are the main pages of the admin dashboard.
That's it. That's the main pages of the admin dashboard.
API
and see all the routes.
Here is propertyRoutes.ts:
import express from 'express'
import multer from 'multer'
import routeNames from '../config/propertyRoutes.config'
import authJwt from '../middlewares/authJwt'
import * as propertyController from '../controllers/propertyController'
const routes = express.Router()
routes.route(routeNames.create).post(authJwt.verifyToken, propertyController.create)
routes.route(routeNames.update).put(authJwt.verifyToken, propertyController.update)
routes.route(routeNames.checkProperty).get(authJwt.verifyToken, propertyController.checkProperty)
routes.route(routeNames.delete).delete(authJwt.verifyToken, propertyController.deleteProperty)
routes.route(routeNames.uploadImage).post([authJwt.verifyToken, multer({ storage: multer.memoryStorage() }).single('image')], propertyController.uploadImage)
routes.route(routeNames.deleteImage).post(authJwt.verifyToken, propertyController.deleteImage)
routes.route(routeNames.deleteTempImage).post(authJwt.verifyToken, propertyController.deleteTempImage)
routes.route(routeNames.getProperty).get(propertyController.getProperty)
routes.route(routeNames.getProperties).post(authJwt.verifyToken, propertyController.getProperties)
routes.route(routeNames.getBookingProperties).post(authJwt.verifyToken, propertyController.getBookingProperties)
routes.route(routeNames.getFrontendProperties).post(propertyController.getFrontendProperties)
export default routes
First of all, we create an Express Router. Then, we create the routes using their name, method, middlewares and controllers.
routeNames contains propertyRoutes route names:
const routes = {
create: '/api/create-property',
update: '/api/update-property',
delete: '/api/delete-property/:id',
uploadImage: '/api/upload-property-image',
deleteTempImage: '/api/delete-temp-property-image/:fileName',
deleteImage: '/api/delete-property-image/:property/:image',
getProperty: '/api/property/:id/:language',
getProperties: '/api/properties/:page/:size',
getBookingProperties: '/api/booking-properties/:page/:size',
getFrontendProperties: '/api/frontend-properties/:page/:size',
checkProperty: '/api/check-property/:id',
}
export default routes
propertyController contains the main business logic regarding locations. We are not going to see all the source code of the controller since it's quite large but we'll take create controller function for example.
Below is Property model:
import { Schema, model } from 'mongoose'
import * as movininTypes from ':movinin-types'
import * as env from '../config/env.config'
const propertySchema = new Schema<env.Property>(
{
name: {
type: String,
required: [true, "can't be blank"],
},
type: {
type: String,
enum: [
movininTypes.PropertyType.House,
movininTypes.PropertyType.Apartment,
movininTypes.PropertyType.Townhouse,
movininTypes.PropertyType.Plot,
movininTypes.PropertyType.Farm,
movininTypes.PropertyType.Commercial,
movininTypes.PropertyType.Industrial,
],
required: [true, "can't be blank"],
},
agency: {
type: Schema.Types.ObjectId,
required: [true, "can't be blank"],
ref: 'User',
index: true,
},
description: {
type: String,
required: [true, "can't be blank"],
},
available: {
type: Boolean,
default: true,
},
image: {
type: String,
},
images: {
type: [String],
},
bedrooms: {
type: Number,
required: [true, "can't be blank"],
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
bathrooms: {
type: Number,
required: [true, "can't be blank"],
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
kitchens: {
type: Number,
default: 1,
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
parkingSpaces: {
type: Number,
default: 0,
validate: {
validator: Number.isInteger,
message: '{VALUE} is not an integer value',
},
},
size: {
type: Number,
},
petsAllowed: {
type: Boolean,
required: [true, "can't be blank"],
},
furnished: {
type: Boolean,
required: [true, "can't be blank"],
},
minimumAge: {
type: Number,
required: [true, "can't be blank"],
min: env.MINIMUM_AGE,
max: 99,
},
location: {
type: Schema.Types.ObjectId,
ref: 'Location',
required: [true, "can't be blank"],
},
address: {
type: String,
},
price: {
type: Number,
required: [true, "can't be blank"],
},
hidden: {
type: Boolean,
default: false,
},
cancellation: {
type: Number,
default: 0,
},
aircon: {
type: Boolean,
default: false,
},
rentalTerm: {
type: String,
enum: [
movininTypes.RentalTerm.Monthly,
movininTypes.RentalTerm.Weekly,
movininTypes.RentalTerm.Daily,
movininTypes.RentalTerm.Yearly,
],
required: [true, "can't be blank"],
},
},
{
timestamps: true,
strict: true,
collection: 'Property',
},
)
const Property = model<env.Property>('Property', propertySchema)
export default Property
Below is Property type:
export interface Property extends Document {
name: string
type: movininTypes.PropertyType
agency: Types.ObjectId
description: string
image: string
images?: string[]
bedrooms: number
bathrooms: number
kitchens?: number
parkingSpaces?: number,
size?: number
petsAllowed: boolean
furnished: boolean
minimumAge: number
location: Types.ObjectId
address?: string
price: number
hidden?: boolean
cancellation?: number
aircon?: boolean
available?: boolean
rentalTerm: movininTypes.RentalTerm
}
A property is composed of:
- A name
- A type (Apartment, Commercial, Farm, House, Industrial, Plot, Townhouse)
- A reference to the agency who created it
- A description
- A main image
- Additional images
- Number of bedrooms
- Number of bathrooms
- Number of kitchens
- Number of parking spaces
- A Size
- Minimum age for rental
- A location
- An address (optional)
- A price
- A rental term (Monthly, Weekly, Daily, Yearly)
- Cancellation price (set it to 0 to be included for free, leave it empty if you don't want to include it, or set the price for cancellation)
- A flag that indicates whether pets are allowed or not
- A flag that indicates whether the property is furnished or not
- A flag that indicates whether the property is hidden or not
- A flag that indicates whether aircon is available or not
- A flag that indicates whether the property is available for rental or not
Below is create controller function:
export const create = async (req: Request, res: Response) => {
const { body }: { body: movininTypes.CreatePropertyPayload } = req
try {
const {
name,
type,
agency,
description,
image: imageFile,
images,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
} = body
const _property = {
name,
type,
agency,
description,
bedrooms,
bathrooms,
kitchens,
parkingSpaces,
size,
petsAllowed,
furnished,
minimumAge,
location,
address,
price,
hidden,
cancellation,
aircon,
rentalTerm,
}
const property = new Property(_property)
await property.save()
// image
const _image = path.join(env.CDN_TEMP_PROPERTIES, imageFile)
if (await helper.exists(_image)) {
const filename = `${property._id}_${Date.now()}${path.extname(imageFile)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_image, newPath)
property.image = filename
} else {
await Property.deleteOne({ _id: property._id })
const err = 'Image file not found'
logger.error(i18n.t('ERROR'), err)
return res.status(400).send(i18n.t('ERROR') + err)
}
// images
property.images = []
if (images) {
let i = 1
for (const img of images) {
const _img = path.join(env.CDN_TEMP_PROPERTIES, img)
if (await helper.exists(_img)) {
const filename = `${property._id}_${uuid()}_${Date.now()}_${i}${path.extname(img)}`
const newPath = path.join(env.CDN_PROPERTIES, filename)
await fs.rename(_img, newPath)
property.images.push(filename)
} else {
await Property.deleteOne({ _id: property._id })
const err = 'Image file not found'
logger.error(i18n.t('ERROR'), err)
return res.status(400).send(i18n.t('ERROR') + err)
}
i += 1
}
}
await property.save()
return res.json(property)
} catch (err) {
logger.error(`[property.create] ${i18n.t('DB_ERROR')} ${JSON.stringify(body)}`, err)
return res.status(400).send(i18n.t('ERROR') + err)
}
}
Frontend
The frontend is a web application built with Node.js, React, MUI and TypeScript. From the frontend, the customer can search for available cars depending on pickup and drop-off points and time, choose a car and proceed to checkout:
- ./frontend/src/assets/ folder contains CSS and images.
- ./frontend/src/pages/ folder contains React pages.
- ./frontend/src/components/ folder contains React components.
- ./frontend/src/services/ contains api client services.
- ./frontend/src/App.tsx is the main React App that contains routes.
- ./frontend/src/index.tsx is the main entry point of the frontend.
TypeScript type definitions are defined in the package ./packages/movinin-types.
App.tsx is the main react App:
import React, { lazy, Suspense } from 'react'
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import env from '@/config/env.config'
import { GlobalProvider } from '@/context/GlobalContext'
import { init as initGA } from '@/common/ga4'
if (env.GOOGLE_ANALYTICS_ENABLED) {
initGA()
}
const SignIn = lazy(() => import('@/pages/SignIn'))
const SignUp = lazy(() => import('@/pages/SignUp'))
const Activate = lazy(() => import('@/pages/Activate'))
const ForgotPassword = lazy(() => import('@/pages/ForgotPassword'))
const ResetPassword = lazy(() => import('@/pages/ResetPassword'))
const Home = lazy(() => import('@/pages/Home'))
const Search = lazy(() => import('@/pages/Search'))
const Property = lazy(() => import('@/pages/Property'))
const Checkout = lazy(() => import('@/pages/Checkout'))
const CheckoutSession = lazy(() => import('@/pages/CheckoutSession'))
const Bookings = lazy(() => import('@/pages/Bookings'))
const Booking = lazy(() => import('@/pages/Booking'))
const Settings = lazy(() => import('@/pages/Settings'))
const Notifications = lazy(() => import('@/pages/Notifications'))
const ToS = lazy(() => import('@/pages/ToS'))
const About = lazy(() => import('@/pages/About'))
const ChangePassword = lazy(() => import('@/pages/ChangePassword'))
const Contact = lazy(() => import('@/pages/Contact'))
const NoMatch = lazy(() => import('@/pages/NoMatch'))
const Agencies = lazy(() => import('@/pages/Agencies'))
const Locations = lazy(() => import('@/pages/Locations'))
const App = () => (
<GlobalProvider>
<Router>
<div className="app">
<Suspense fallback={<></>}>
<Routes>
<Route path="/sign-in" element={<SignIn />} />
<Route path="/sign-up" element={<SignUp />} />
<Route path="/activate" element={<Activate />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/" element={<Home />} />
<Route path="/search" element={<Search />} />
<Route path="/property" element={<Property />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/checkout-session/:sessionId" element={<CheckoutSession />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/booking" element={<Booking />} />
<Route path="/settings" element={<Settings />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/change-password" element={<ChangePassword />} />
<Route path="/about" element={<About />} />
<Route path="/tos" element={<ToS />} />
<Route path="/contact" element={<Contact />} />
<Route path="/agencies" element={<Agencies />} />
<Route path="/destinations" element={<Locations />} />
<Route path="*" element={<NoMatch />} />
</Routes>
</Suspense>
</div>
</Router>
</GlobalProvider>
)
export default App
We are using React lazy loading to load each route.
We are not going to cover each page of the frontend, but you can browse the and see each one.
Admin Dashboard
The admin dashboard is a web application built with Node.js, React, MUI and TypeScript. From the backend, admins can create and manage suppliers, cars, locations, customers and bookings. When new suppliers are created from the backend, they will receive an email prompting them to create an account in order to access the admin dashboard and manage their car fleet and bookings.
- ./backend/assets/ folder contains CSS and images.
- ./backend/pages/ folder contains React pages.
- ./backend/components/ folder contains React components.
- ./backend/services/ contains api client services.
- ./backend/App.tsx is the main React App that contains routes.
- ./backend/index.tsx is the main entry point of the admin dashboard.
TypeScript type definitions are defined in the package ./packages/movinin-types.
App.tsx of the admin dashboard follow similar logic like App.tsx of the frontend.
We are not going to cover each page of the admin dashboard but you can browse the
SOCIAL SHARE CARD GENERATOR