diff --git a/.optimize-cache.json b/.optimize-cache.json index e82ec53205..2e0c369a86 100644 --- a/.optimize-cache.json +++ b/.optimize-cache.json @@ -1051,6 +1051,15 @@ "images/blog/type-generation-feature/cover.png": "c5ca682b5abf9fb719b3d0056aed821255d961a547fc83e1d27a0044d3dc3f5d", "images/blog/type-generation-feature/workflow.png": "bcd3c053c900e19a7cccaadde9d94a9d6c743e52fdb778617bb7b7623cd2c711", "images/blog/typescript-7-faster-with-go/cover.png": "cb9e838dd23e53a2e7d5776c9faba11ae5c6366ac27281e358885649261ddd31", + "images/blog/uber-clone-nextjs-appwrite/cover.png": "6cc2b5a1c56bb79d0e3777da31cd22d60459871c94874e5417458f788ae1d764", + "images/blog/uber-clone-nextjs-appwrite/driver-accepted.png": "0dae6fcaef9213efcc9f1e80014694f64798aed01517a2d922369bda13d76f55", + "images/blog/uber-clone-nextjs-appwrite/driver-ride-request.png": "4efb400863027632dbd4431950f6ac5594e4406fbaca220e2fb21e95005c12cc", + "images/blog/uber-clone-nextjs-appwrite/end-ride.png": "0f76a2fd400058b78b9730246e6be108a28f5ca0d36879373cbbfe50777d9466", + "images/blog/uber-clone-nextjs-appwrite/project-overview.png": "74bff9573aa1549a7219b148dad16f781d089be8868c8b800256a704d603b5b2", + "images/blog/uber-clone-nextjs-appwrite/ride-pending.png": "a63cd3f8956739337d0b43471ce113fdc0059862a0f30c0b0a40108b8921f605", + "images/blog/uber-clone-nextjs-appwrite/ride-request.png": "fdf54975a6506af117e7d615c639ccd034337381b3ba899f2935bcbd2de9cc74", + "images/blog/uber-clone-nextjs-appwrite/rider-otp-display.png": "12279215dadb14813946bbe09452670fb2e7b04dcdf924ca7e0cd8db5886270d", + "images/blog/uber-clone-nextjs-appwrite/signup-page.png": "78d2dd35e3cfa56225bbe0912eac6022a8d92eef59a16cfdda7251aa5c4adf8c", "images/blog/understand-data-queries.png": "e85cb6ce2644feac4242000a6f7a87fb3f8e07c0b954c1cef17f0feab523c2c5", "images/blog/understand-oauth2/cover.png": "f263e8dae70606276f8bba28b74a2521645cf45c969b91b4fd9975d917e050f0", "images/blog/understanding-idp-vs-sp-initiated-sso/cover.png": "2a01d6d18f165d0d684dfa3d4bb5acd4712b2ed6a62e87a46025f264756c058e", diff --git a/src/routes/blog/post/uber-clone-nextjs-appwrite/+page.markdoc b/src/routes/blog/post/uber-clone-nextjs-appwrite/+page.markdoc new file mode 100644 index 0000000000..12d56a915f --- /dev/null +++ b/src/routes/blog/post/uber-clone-nextjs-appwrite/+page.markdoc @@ -0,0 +1,627 @@ +--- +layout: post +title: Build an Uber clone with Geo Queries and Realtime +description: Learn how to build a real-time ride-hailing app using Appwrite's Geo Queries and Realtime features with Next.js and Leaflet maps. +date: 2026-04-03 +cover: /images/blog/uber-clone-nextjs-appwrite/cover.png +timeToRead: 15 +author: atharva +category: tutorial, product +featured: false +--- + +Ride-hailing apps like Uber depend on two things: knowing where people are and pushing updates the moment something changes. The driver needs to see nearby ride requests. The rider needs to know when a driver accepts and where that driver is right now. + +In this tutorial, you will build a ride-hailing app with Next.js and Appwrite that uses **geo queries** to match drivers with nearby riders and **realtime subscriptions** to keep both sides in sync throughout the trip. You will also use Appwrite's native **point data type** and **spatial indexes** to make location queries fast and simple. + +# Prerequisites + +- An [Appwrite Cloud](https://cloud.appwrite.io) account or a self-hosted Appwrite instance +- [Node.js](https://nodejs.org/) 18+ installed +- Basic knowledge of React, Next.js, and TypeScript + +# What you will build + +The app has two roles: **rider** and **driver**. Here is the flow: + +1. A rider opens the map, picks a pickup and drop-off location, and requests a ride +2. Nearby drivers see the pending ride appear on their dashboard (found via a geo query) +3. A driver accepts the ride, and both sides switch to a realtime-powered tracking view +4. The driver verifies the rider with a one-time password, starts the trip, and completes it at the drop-off + +The tech stack is Next.js for the frontend, Tailwind CSS for styling, Leaflet with OpenStreetMap tiles for the map, and Appwrite for authentication, database, and realtime. + +# Set up the Appwrite project + +Create a new project in the Appwrite Console. Note the **project ID** and **API endpoint** from the overview page. + +![Appwrite project overview](/images/blog/uber-clone-nextjs-appwrite/project-overview.png) + +# Create the database + +Navigate to **Databases** and create a new database called **uber-clone** (ID: `uber-clone`). You will create three tables inside it. + +## Profiles table + +Create a table called **profiles** (ID: `profiles`) with the following columns: + +| Column | Type | Required | +| --- | --- | --- | +| `userId` | varchar(255) | Yes | +| `name` | varchar(255) | Yes | +| `role` | enum [driver, rider] | Yes | + +## Driver Locations table + +Create a table called **driver-locations** (ID: `driver-locations`) with the following columns: + +| Column | Type | Required | +| --- | --- | --- | +| `driverId` | varchar(255) | Yes | +| `location` | point | Yes | +| `available` | boolean | Yes | + +Add two indexes: +- A **unique** index on `driverId` +- A **spatial** index on `location` + +The spatial index is what makes distance queries fast. Without it, Appwrite would have to scan every row to calculate distances. + +## Rides table + +Create a table called **rides** (ID: `rides`) with the following columns: + +| Column | Type | Required | +| --- | --- | --- | +| `riderId` | varchar(255) | Yes | +| `driverId` | varchar(255) | No | +| `pickupAddress` | varchar(512) | Yes | +| `dropAddress` | varchar(512) | Yes | +| `otp` | varchar(6) | Yes | +| `pickupLocation` | point | Yes | +| `dropLocation` | point | Yes | +| `driverLocation` | point | No | +| `riderLocation` | point | No | +| `status` | enum [pending, accepted, riding, completed, cancelled] | Yes | + +Add three indexes: +- A **key** index on `status`, `riderId`, and `driverId` for filtering rides by state +- A **spatial** index on `pickupLocation` for finding nearby pending rides + +The `pickupLocation` spatial index is the backbone of the driver matching system. It powers the `distanceLessThan` query that finds rides within a given radius. + +# Set up the Next.js project + +Scaffold a new Next.js app and install the dependencies: + +```bash +pnpx create-next-app@latest uber-clone --typescript --tailwind --app +cd uber-clone +pnpm install appwrite leaflet react-leaflet +pnpm install -D @types/leaflet +``` + +## Environment variables + +Create a `.env.local` file in the project root: + +```bash +NEXT_PUBLIC_APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1 +NEXT_PUBLIC_APPWRITE_PROJECT_ID= +APPWRITE_API_KEY= +``` + +Replace `` with your project ID from the Console, and update the endpoint to match your project's region. + +To create the API key, go to **Overview > API keys** in your project and click **Create API key**. Give it a name and select **all scopes** (or at minimum the Database and Auth scopes). Copy the secret and paste it as `APPWRITE_API_KEY`. This key is server-only and never exposed to the browser since it is not prefixed with `NEXT_PUBLIC_`. + +## Configuration + +Create `src/lib/config.ts` with constants for the database and table IDs: + +```ts +export const APPWRITE_ENDPOINT = + process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://fra.cloud.appwrite.io/v1'; +export const APPWRITE_PROJECT_ID = + process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || ''; + +export const DATABASE_ID = 'uber-clone'; +export const PROFILES_TABLE_ID = 'profiles'; +export const DRIVER_LOCATIONS_TABLE_ID = 'driver-locations'; +export const RIDES_TABLE_ID = 'rides'; +``` + +## Initialize the Appwrite SDK + +Create `src/lib/appwrite.ts` to set up the client SDK with your project credentials: + +```ts +'use client'; + +import { Client, Account, TablesDB, Realtime, Channel, ID, Query } from 'appwrite'; +import { APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID } from './config'; + +const client = new Client() + .setEndpoint(APPWRITE_ENDPOINT) + .setProject(APPWRITE_PROJECT_ID); + +const account = new Account(client); +const tablesDB = new TablesDB(client); +const realtime = new Realtime(client); + +export { client, account, tablesDB, realtime, Channel, ID, Query }; +``` + +The `TablesDB` service handles all database operations. The `Realtime` service provides live subscriptions to row changes. Both share the same `client` so they use the same session. + +# Authentication + +Create `src/lib/auth.ts` to handle signup, login, and profile lookup. Signup creates the Appwrite account and session on the client, then delegates profile creation to a server action that sets proper row-level permissions: + +```ts +'use client'; + +import { account, tablesDB, ID } from './appwrite'; +import { DATABASE_ID, PROFILES_TABLE_ID } from './config'; +import { createProfileAction } from '@/app/actions/auth'; +import type { Profile } from './types'; + +export async function signup( + email: string, + password: string, + name: string, + role: 'driver' | 'rider' +) { + await account.create({ userId: ID.unique(), email, password, name }); + await account.createEmailPasswordSession({ email, password }); + const user = await account.get(); + + // Create profile via server action (sets proper permissions) + await createProfileAction(user.$id, name, role); + + return user; +} + +export async function login(email: string, password: string) { + await account.createEmailPasswordSession({ email, password }); + return account.get(); +} + +export async function logout() { + await account.deleteSession({ sessionId: 'current' }); +} + +export async function getProfile(userId: string): Promise { + try { + const row = await tablesDB.getRow({ + databaseId: DATABASE_ID, + tableId: PROFILES_TABLE_ID, + rowId: userId, + }); + return { + userId: row.userId as string, + name: row.name as string, + role: row.role as 'driver' | 'rider', + }; + } catch { + return null; + } +} +``` + +The profile row ID is set to the user's ID (`rowId: user.$id`), so you can look up any user's profile in a single call without querying. + +During signup, users pick their role (rider or driver). This role determines which dashboard they see after logging in. + +![Signup page with role selection](/images/blog/uber-clone-nextjs-appwrite/signup-page.png) + +# Coordinate helpers + +Appwrite stores points as `[longitude, latitude]`, but Leaflet expects `[latitude, longitude]`. Create `src/lib/geo.ts` with helper functions to convert between the two: + +```ts +/** Convert Appwrite [lng, lat] to Leaflet [lat, lng] */ +export function toLeaflet(point: [number, number]): [number, number] { + return [point[1], point[0]]; +} + +/** Convert Leaflet [lat, lng] to Appwrite [lng, lat] */ +export function toAppwrite(point: [number, number]): [number, number] { + return [point[1], point[0]]; +} +``` + +This is a common source of bugs when working with geo data. GeoJSON and most databases (including Appwrite) use longitude-first ordering, while Leaflet and most human-readable formats use latitude-first. Mixing them up will place your markers in the ocean. + +# Geo queries: finding nearby rides + +This is the core feature that makes the app work. When a driver goes online, they need to see pending ride requests near their current location. Appwrite's `Query.distanceLessThan()` combined with a spatial index makes this a single query. + +All write operations use Next.js server actions with the `node-appwrite` admin client. This keeps the API key on the server and lets us set row-level permissions. Every server action file starts with this helper: + +```ts +'use server'; + +import { Client, TablesDB, ID, Query, Permission, Role } from 'node-appwrite'; + +function getAdminClient() { + const client = new Client() + .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!) + .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!) + .setKey(process.env.APPWRITE_API_KEY!); + return new TablesDB(client); +} +``` + +## Creating a ride + +When a rider requests a ride, a server action creates the row using the admin client. This lets us set row-level permissions so only the rider and the assigned driver can modify the ride later: + +```ts +// src/app/actions/rides.ts +'use server'; + +import { Client, TablesDB, ID, Permission, Role } from 'node-appwrite'; + +export async function createRideAction( + riderId: string, + pickupLocation: [number, number], + dropLocation: [number, number], + pickupAddress: string, + dropAddress: string +) { + const tablesDB = getAdminClient(); + const crypto = require('crypto'); + const otp = crypto.randomInt(100000, 1000000).toString(); + + const row = await tablesDB.createRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: ID.unique(), + data: { + riderId, + driverId: null, + pickupLocation, + dropLocation, + pickupAddress, + dropAddress, + status: 'pending', + otp, + driverLocation: null, + riderLocation: null, + }, + permissions: [ + Permission.read(Role.users()), + Permission.update(Role.user(riderId)), + ], + }); + return JSON.parse(JSON.stringify(row)); +} +``` + +The `permissions` array gives all authenticated users read access (so drivers can find pending rides via the geo query), but only the rider can update it. When a driver accepts, the server action updates the permissions to include them. + +The `pickupLocation` and `dropLocation` values are `[longitude, latitude]` arrays. Appwrite stores them as native point types, which the spatial index can operate on. + +The `otp` is a 6-digit code that the rider shares with the driver at pickup to verify identity before the trip starts. + +The rider taps two points on the map to set a pickup and drop-off, then confirms the request. + +![Ride request with pickup selection](/images/blog/uber-clone-nextjs-appwrite/ride-request.png) + +Once submitted, the ride enters the `pending` state. The rider sees pickup and drop-off markers on the map while waiting for a driver to accept. + +![Waiting for driver with markers on the map](/images/blog/uber-clone-nextjs-appwrite/ride-pending.png) + +## Updating driver location + +When a driver goes online, a server action stores (or updates) their location every 5 seconds. The admin client handles the write, and row-level permissions ensure only the driver can modify their own location row: + +```ts +// src/app/actions/driver-locations.ts +'use server'; + +import { Client, TablesDB, Permission, Role, AppwriteException } from 'node-appwrite'; + +export async function updateDriverLocationAction( + driverId: string, + location: [number, number], + available: boolean +) { + const tablesDB = getAdminClient(); + try { + await tablesDB.updateRow({ + databaseId: DATABASE_ID, + tableId: DRIVER_LOCATIONS_TABLE_ID, + rowId: driverId, + data: { driverId, location, available }, + }); + } catch (error) { + // Only create on 404 (row not found), re-throw other errors + if (!(error instanceof AppwriteException) || error.code !== 404) { + throw error; + } + await tablesDB.createRow({ + databaseId: DATABASE_ID, + tableId: DRIVER_LOCATIONS_TABLE_ID, + rowId: driverId, + data: { driverId, location, available }, + permissions: [ + Permission.read(Role.users()), + Permission.update(Role.user(driverId)), + Permission.delete(Role.user(driverId)), + ], + }); + } +} +``` + +The try/catch pattern handles the first-time case: if the driver has no location row yet, the update fails and we create one instead. Using the driver's user ID as the row ID means each driver has exactly one location row. + +## Finding nearby pending rides + +With driver locations stored, we can now match riders and drivers. The driver's dashboard polls every 5 seconds to find pending rides within 5 kilometers: + +```ts +export async function getNearbyPendingRides( + driverLocation: [number, number], + radiusMeters: number = 5000 +) { + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + queries: [ + Query.equal('status', 'pending'), + Query.distanceLessThan( + 'pickupLocation', + driverLocation, + radiusMeters + ), + ], + }); + return result.rows; +} +``` + +`Query.distanceLessThan('pickupLocation', driverLocation, 5000)` tells Appwrite to find all rows where the `pickupLocation` point is within 5,000 meters of the driver's current coordinates. The spatial index on `pickupLocation` makes this efficient even with thousands of rows. + +The same pattern works for finding nearby available drivers from the rider's perspective: + +```ts +export async function getNearbyDrivers( + location: [number, number], + radiusMeters: number = 5000 +) { + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: DRIVER_LOCATIONS_TABLE_ID, + queries: [ + Query.equal('available', true), + Query.distanceLessThan('location', location, radiusMeters), + ], + }); + return result.rows; +} +``` + +When a pending ride is within range, the driver sees the pickup and drop-off addresses with an accept button. + +![Driver seeing a nearby ride request](/images/blog/uber-clone-nextjs-appwrite/driver-ride-request.png) + +## Accepting a ride + +When a driver accepts a ride, two drivers could try to accept the same ride at the same time. To prevent this, we wrap the update in an Appwrite [transaction](/docs/products/databases/transactions). If another driver modifies the row between staging and commit, the commit fails with a conflict error: + +```ts +export async function acceptRideAction(rideId: string, driverId: string) { + const tablesDB = getAdminClient(); + + // Read the ride to get the riderId for scoped permissions + const current = await tablesDB.getRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: rideId, + }); + const riderId = current.riderId as string; + + // Use a transaction to prevent two drivers accepting the same ride + const tx = await tablesDB.createTransaction(); + + try { + // Stage the update within the transaction + await tablesDB.updateRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: rideId, + data: { driverId, status: 'accepted' }, + permissions: [ + Permission.read(Role.users()), + Permission.update(Role.user(riderId)), + Permission.update(Role.user(driverId)), + ], + transactionId: tx.$id, + }); + + // Commit - fails with conflict if the row was modified by another driver + await tablesDB.updateTransaction({ + transactionId: tx.$id, + commit: true, + }); + + const row = await tablesDB.getRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: rideId, + }); + return JSON.parse(JSON.stringify(row)); + } catch { + // Roll back on any failure (conflict or otherwise) + try { + await tablesDB.updateTransaction({ + transactionId: tx.$id, + rollback: true, + }); + } catch { + // Transaction may have already expired + } + throw new Error('Ride is no longer available'); + } +} +``` + +After accepting, the driver sees the pickup location on the map and heads there. + +![Driver accepted, heading to pickup](/images/blog/uber-clone-nextjs-appwrite/driver-accepted.png) + +# Realtime: live ride tracking + +Once a ride is accepted, both the rider and driver need live updates. The rider wants to see the driver approaching. The driver wants to know if the rider cancels. Appwrite's realtime subscriptions handle this by pushing changes the moment a row updates. + +## Subscribing to a ride row + +Both dashboards use the same subscription pattern. The `subscribe` call returns an object with a `close()` method that you must call to clean up the connection when the component unmounts or the ride ends: + +```ts +import { realtime, Channel } from './appwrite'; +import { DATABASE_ID, RIDES_TABLE_ID } from './config'; + +// Store the subscription so we can close it later +const subscriptionRef = useRef<{ close: () => Promise } | null>(null); + +const subscribeToRide = async (rideId: string) => { + // Close any previous subscription first + await subscriptionRef.current?.close(); + + const unsub = await realtime.subscribe( + Channel.tablesdb(DATABASE_ID).table(RIDES_TABLE_ID).row(rideId), + (response) => { + const payload = response.payload; + + if (payload.status === 'accepted') { + // Driver accepted - show driver info and start tracking + } else if (payload.status === 'riding') { + // Trip started - show live driver location + } else if (payload.status === 'completed') { + // Trip finished - close subscription and show summary + subscriptionRef.current?.close(); + } else if (payload.status === 'cancelled') { + // Ride was cancelled - close subscription + subscriptionRef.current?.close(); + } + } + ); + subscriptionRef.current = unsub; +}; + +// In your cleanup effect: +useEffect(() => { + return () => { + subscriptionRef.current?.close(); + }; +}, []); +``` + +The channel `Channel.tablesdb(DATABASE_ID).table(RIDES_TABLE_ID).row(rideId)` targets a single row. Every time that row is updated (status change, location update, etc.), the callback fires with the full updated row as the payload. Closing the subscription when the ride ends prevents memory leaks and stale callbacks. + +## The ride lifecycle + +The complete ride flow through the status field looks like this: + +1. **pending** - Rider created the request. Drivers poll with `getNearbyPendingRides()` to discover it. +2. **accepted** - A driver accepted. Both sides subscribe to the ride row via realtime. The driver heads to the pickup point. +3. **riding** - The driver verified the rider's OTP and started the trip. Live location tracking is active. +4. **completed** - The driver ended the ride at the drop-off. Both subscriptions close. +5. **cancelled** - Either party cancelled. Subscriptions close. + +When the driver arrives at the pickup, the rider sees the OTP and shares it with the driver to verify their identity. + +![Rider showing OTP to share with driver](/images/blog/uber-clone-nextjs-appwrite/rider-otp-display.png) + +## Live location updates + +Once the ride is accepted, both parties send their location to the ride row every 5 seconds via a server action: + +```ts +// Server action - runs on the server with admin privileges +export async function updateRideLocationAction( + rideId: string, + field: 'driverLocation' | 'riderLocation', + location: [number, number] +) { + const tablesDB = getAdminClient(); + await tablesDB.updateRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: rideId, + data: { [field]: location }, + }); +} +``` + +Because both sides are subscribed to the same ride row, when the driver updates `driverLocation`, the rider immediately receives the new coordinates through the realtime callback and can update the map marker. The same happens in reverse with `riderLocation`. + +# Completing the ride + +The driver can end the ride when they are near the drop-off location. The app uses a haversine distance check on the client side to enable the button: + +```ts +export function haversine( + a: [number, number], + b: [number, number] +): number { + const R = 6_371_000; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(b[1] - a[1]); + const dLng = toRad(b[0] - a[0]); + const sinLat = Math.sin(dLat / 2); + const sinLng = Math.sin(dLng / 2); + const h = + sinLat * sinLat + + Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * sinLng * sinLng; + return 2 * R * Math.asin(Math.sqrt(h)); +} +``` + +When the haversine distance between the driver's current position and the drop-off point is under a threshold, the "End Ride" button becomes active. Ending the ride updates the status to `completed`, which triggers the realtime callback on both sides. + +```ts +export async function endRideAction(rideId: string) { + const tablesDB = getAdminClient(); + const row = await tablesDB.updateRow({ + databaseId: DATABASE_ID, + tableId: RIDES_TABLE_ID, + rowId: rideId, + data: { status: 'completed' }, + }); + return JSON.parse(JSON.stringify(row)); +} +``` + +When the driver is close enough to the drop-off, the "End Ride" button activates. + +![End ride button enabled near drop-off](/images/blog/uber-clone-nextjs-appwrite/end-ride.png) + +# Key takeaways + +- **Point data type** stores geographic coordinates natively in `[longitude, latitude]` format +- **Spatial indexes** make distance queries performant without full table scans +- **`Query.distanceLessThan()`** finds rows within a given radius of a point in a single query +- **Realtime subscriptions** push row changes instantly to connected clients, eliminating the need for polling during active rides +- **Channel targeting** lets you subscribe to a single row (`Channel.tablesdb().table().row()`) for precise updates +- **Server actions with admin client** handle all writes, setting row-level permissions so users can only modify their own data +- **Row security** scopes ride access to the rider and assigned driver after acceptance + +# Next steps + +You now have a working ride-hailing app that uses geo queries to match riders and drivers and realtime to keep both sides in sync. These two features combine to solve a problem that traditionally requires specialized infrastructure, and Appwrite makes it possible with a few queries and a subscription call. + +The full source code is available on GitHub at [appwrite-community/uber-clone](https://github.com/appwrite-community/uber-clone). Clone it, swap in your project credentials, and start building on top of it. + +To go deeper into the features used in this tutorial: + +- [Geo queries](/docs/products/databases/geo-queries) +- [Realtime](/docs/apis/realtime) +- [Transactions](/docs/products/databases/transactions) +- [Databases](/docs/products/databases) +- [Authentication](/docs/products/auth) +- [Join the Appwrite Discord](https://appwrite.io/discord) diff --git a/static/images/blog/uber-clone-nextjs-appwrite/cover.png b/static/images/blog/uber-clone-nextjs-appwrite/cover.png new file mode 100644 index 0000000000..6d55c00b56 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/cover.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/driver-accepted.png b/static/images/blog/uber-clone-nextjs-appwrite/driver-accepted.png new file mode 100644 index 0000000000..df3aa8eb3e Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/driver-accepted.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/driver-ride-request.png b/static/images/blog/uber-clone-nextjs-appwrite/driver-ride-request.png new file mode 100644 index 0000000000..ac39d76811 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/driver-ride-request.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/end-ride.png b/static/images/blog/uber-clone-nextjs-appwrite/end-ride.png new file mode 100644 index 0000000000..a2228defa4 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/end-ride.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/project-overview.png b/static/images/blog/uber-clone-nextjs-appwrite/project-overview.png new file mode 100644 index 0000000000..d70dd278e2 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/project-overview.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/ride-pending.png b/static/images/blog/uber-clone-nextjs-appwrite/ride-pending.png new file mode 100644 index 0000000000..35269522e0 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/ride-pending.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/ride-request.png b/static/images/blog/uber-clone-nextjs-appwrite/ride-request.png new file mode 100644 index 0000000000..fdd54cbaa6 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/ride-request.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/rider-otp-display.png b/static/images/blog/uber-clone-nextjs-appwrite/rider-otp-display.png new file mode 100644 index 0000000000..ea66b2d884 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/rider-otp-display.png differ diff --git a/static/images/blog/uber-clone-nextjs-appwrite/signup-page.png b/static/images/blog/uber-clone-nextjs-appwrite/signup-page.png new file mode 100644 index 0000000000..f08b147fd6 Binary files /dev/null and b/static/images/blog/uber-clone-nextjs-appwrite/signup-page.png differ