Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/dev0302/nextjs-project-1/llms.txt

Use this file to discover all available pages before exploring further.

AnonMessage stores all application data in MongoDB through two Mongoose models: User (which embeds Message subdocuments directly inside each user document) and OTP (a short-lived collection with a five-minute TTL index). Both models are fully TypeScript-typed through co-located interfaces.

Message Interface and Schema

Messages are not a standalone collection. They are embedded subdocuments inside each User document. The Message interface extends Mongoose’s Document so that each embedded object inherits MongoDB document fields such as _id and save().
export interface Message extends Document {
    content: string;
    createdAt: Date;
}

const MessageSchema: mongoose.Schema<Message> = new mongoose.Schema({
    content: {
        type: String,
        required: true
    },
    createdAt: {
        type: Date,
        required: true,
        default: Date.now
    }
})

User Interface and Schema

The IUser interface describes the full shape of a user document, including the embedded messages array.
export interface IUser extends Document {
    username: string;
    email: string;
    password: string;
    isVerified: boolean;
    isAcceptingMessages: boolean;
    messages: Message[];
}

const UserSchema: mongoose.Schema<IUser> = new mongoose.Schema({
    username: {
        type: String,
        required: [true, 'Username is required'],
        trim: true,
        unique: true,
    },
    email: {
        type: String,
        required: [true, 'Email is required'],
        unique: true,
        match: [/.+\@.+\..+/, 'Please use a valid email address'],
    },
    password: {
        type: String,
        required: [true, 'Password is required'],
    },
    isVerified: {
        type: Boolean,
        default: false,
    },
    isAcceptingMessages: {
        type: Boolean,
        default: true,
    },
    messages: [MessageSchema],
})
Passwords are never stored in plain text. The POST /api/sign-up route calls bcrypt.hash(password, 10) before calling User.create(), and auth.ts uses bcrypt.compare() during sign-in.

Field Reference

FieldTypeDefaultNotes
usernameStringRequired, unique, trimmed
emailStringRequired, unique, regex-validated
passwordStringRequired; stored as bcrypt hash
isVerifiedBooleanfalseSet to true after OTP verification at sign-up
isAcceptingMessagesBooleantrueToggled via POST /api/accept-messages
messagesMessage[][]Embedded array of MessageSchema subdocuments

Model Export — Next.js Safe Pattern

const User = (mongoose.models.User as mongoose.Model<IUser>)
    || mongoose.model<IUser>("User", UserSchema);

export default User;
Next.js hot-module reloading can execute module-level code multiple times within the same Node.js process. The || guard checks mongoose.models.User first — if the model was already registered in a previous render cycle it reuses it, otherwise it creates a new one. Without this guard Mongoose would throw Cannot overwrite 'User' model once compiled.

Embedded Documents Pattern

Storing messages inside the user document (rather than in a separate messages collection with a foreign key) is a deliberate MongoDB design choice.

Atomic push

Adding a new message is a single atomic $push operation on the user document. There is no separate insert into a second collection and no transaction needed.

Atomic pull

Deleting a message uses $pull — it removes the matching subdocument by _id in one update. The PATCH /api/delete-message route demonstrates this directly.

Single query retrieval

The dashboard fetches a user and all their messages in one query via an aggregation pipeline ($match → $unwind → $sort → $group). No joins or second queries are required.

Simple data shape

Because messages belong to exactly one user and are never queried independently, embedding avoids the overhead of a join collection and keeps the schema straightforward.
A stored user document looks like this:
{
  "_id": "665a1f...",
  "username": "alice",
  "email": "alice@example.com",
  "password": "$2b$10$...",
  "isVerified": true,
  "isAcceptingMessages": true,
  "messages": [
    { "_id": "665a20...", "content": "What's your biggest goal this year?", "createdAt": "2024-06-01T10:00:00Z" },
    { "_id": "665a21...", "content": "You seem really creative!", "createdAt": "2024-06-01T09:30:00Z" }
  ]
}

OTP Model

The OTP collection is intentionally ephemeral. Each document holds one email-OTP pair and automatically deletes itself after five minutes via a MongoDB TTL index.
export interface IOTP extends Document {
    email: string;
    otp: string;
    createdAt: Date;
}

const OTPSchema = new Schema<IOTP>({
    email: {
        type: String,
        trim: true,
        required: true,
    },
    otp: {
        type: String,
        required: true,
    },
    createdAt: {
        type: Date,
        default: Date.now,
        expires: 5 * 60,   // TTL: 300 seconds
    },
})

Pre-Save Hook — Automatic Email Dispatch

When a new OTP document is saved, the pre("save") hook fires and calls mailSender to deliver the OTP email via Brevo. The hook only fires when this.isNew is true, preventing re-sends on accidental re-saves.
OTPSchema.pre("save", async function (next) {
    if (this.isNew) {
        await sendVerificationEmail(this.email, this.otp)
    }
})
The sendVerificationEmail helper calls mailSender with the recipient address, the subject "Verification Email", and the HTML body produced by otpTemplate(otp).

Safe Model Export

const OTP: Model<IOTP> =
    mongoose.models.OTP ||
    mongoose.model<IOTP>("OTP", OTPSchema)

export default OTP
The same mongoose.models.OTP || mongoose.model(...) guard used in User.ts prevents duplicate model registration during hot reloads.
The POST /api/send-otp route uses a while (true) retry loop to handle OTP uniqueness collisions. If OTP.create() throws a duplicate-key error (error.code === 11000), it regenerates the OTP and retries rather than propagating the error.

Database Connection — dbConnect()

type connectionObject = {
    isConnected?: number
}

const connection: connectionObject = {}

async function dbConnect(): Promise<void> {

    if (connection.isConnected) {
        console.log("Already Connected to database");
        return
    }

    if (!process.env.DATABASE_URL) {
        throw new Error("DATABASE_URL is not defined in environment variables");
    }

    try {
        const db = await mongoose.connect(process.env.DATABASE_URL || '', {})
        connection.isConnected = db.connections[0].readyState;
        console.log("Database Connected Successfully.");

    } catch (error) {
        console.error("Error connecting to database:", error);
        process.exit(1);
    }
}

export default dbConnect;
1

Check isConnected

The module-level connection object persists across hot reloads. If connection.isConnected is already set, dbConnect() returns immediately without opening a new connection.
2

Validate environment variable

Throws a clear Error if DATABASE_URL is missing so misconfiguration is caught at startup.
3

Connect and record state

Calls mongoose.connect() and stores db.connections[0].readyState (a numeric enum where 1 means connected) into connection.isConnected for subsequent calls.
4

Fail loudly on error

If mongoose.connect() throws, process.exit(1) terminates the process immediately. This is intentional — running the app without a database connection produces confusing per-request failures rather than one clear startup error.

SafeUser Type

SafeUser is the non-sensitive subset of IUser returned in API responses. It deliberately omits the password field.
import { Message } from "../models/User";

export interface SafeUser {
    id: string;
    username: string;
    email: string;
    isVerified: boolean;
    isAcceptingMessages: boolean;
    messages: Message[];
}
SafeUser is used as the generic parameter for ApiResponse<SafeUser> in the sign-up and delete-message routes, ensuring the data field of the response always conforms to the safe shape.

ApiResponse<T> Type

import { Message } from "../models/User";

export interface ApiResponse<T = null> {
    success: boolean;
    message: string;
    data?: T;
}
Every API route in AnonMessage returns a JSON body that matches this envelope. success is a boolean flag the frontend checks first. message is a human-readable status string. data is optional and typed by T — routes that carry no payload simply omit it (the default T = null makes data? an optional field). Usage examples:
// Simple acknowledgement — no data payload
const response: ApiResponse = {
    success: false,
    message: "User Already Exists"
}

// Rich payload — typed with SafeUser
const response: ApiResponse<SafeUser> = {
    success: true,
    message: "User Registered Successfully",
    data: {
        id: user._id.toString(),
        username: user.username,
        email: user.email,
        isVerified: user.isVerified,
        isAcceptingMessages: user.isAcceptingMessages,
        messages: user.messages
    }
}

Zod Validation Schemas

All five schemas live in src/app/schemas/ and are used both in client-side forms (via zodResolver) and in server-side route handlers (via safeParse).
import z from "zod";

export const usernameSchema = z
    .string()
    .min(2, "Username must be atleast 2 characters")
    .max(20, "username must not be more than 20")
    .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores")

export const signUpSchema = z.object({
    username: usernameSchema,
    email: z.email({ error: "Invalid email address" }),
    password: z.string().min(3, { error: "password must be min 3 chars" })
})
usernameSchema is also exported separately so check-username-unique/route.ts can reuse it for query-param validation without importing the full sign-up schema.
import z from "zod"

export const signInSchema = z.object({
    username: z.string(),
    password: z.string()
})
Sign-in validation is intentionally minimal — Zod confirms the fields are present strings. The actual credential check (user lookup + bcrypt.compare) happens in auth.ts authorize().
import z from "zod"

export const messageSchema = z.object({
    content: z
        .string()
        .min(10, { message: "content must be of min length 10 chars" })
        .max(300, { message: "content must not be longer than 300 chars" })
})
Enforces a 10-character minimum (prevents empty or trivially short messages) and a 300-character maximum (keeps the UI clean and prevents abuse).
import z from "zod"

export const acceptMessageSchema = z.object({
    acceptMessage: z.boolean()
})
Used in POST /api/accept-messages to validate that the incoming body contains a strict boolean. Zod’s z.boolean() rejects strings like "true" or numbers like 1, preventing accidental coercion bugs.
import z from "zod"

export const verifySchema = z.object({
    code: z.string().length(6, "Verification code must be 6 digits")
})
z.string().length(6) requires exactly six characters — matching the six-digit OTPs generated by otp-generator in send-otp/route.ts. Wrapped in z.object() because the data arrives as { code: "123456" } from the form.

Build docs developers (and LLMs) love