Skip to content

Next.js Integration

Complete guide to using Monch with Next.js App Router.

Project Structure

src/
├── lib/
│   └── models/
│       ├── index.ts
│       ├── user.model.ts
│       └── post.model.ts
├── app/
│   ├── actions/
│   │   └── user.actions.ts
│   ├── users/
│   │   ├── page.tsx
│   │   └── [id]/
│   │       └── page.tsx
│   └── api/
│       └── users/
│           └── route.ts
└── components/
    └── UserCard.tsx

Model Definition

typescript
// lib/models/user.model.ts
import { collection, field, type ModelOf, type SerializedOf } from '@codician-team/monch';

export const Users = collection({
  name: 'users',
  schema: {
    _id: field.id(),
    name: field.string().min(1),
    email: field.email(),
    avatar: field.url().optional(),
    role: field.enum(['user', 'admin']).default('user'),
  },
  timestamps: true,
  indexes: [
    { key: { email: 1 }, unique: true },
  ],
});

export type User = ModelOf<typeof Users>;
export type SerializedUser = SerializedOf<typeof Users>;
typescript
// lib/models/index.ts
export { Users, type User, type SerializedUser } from './user.model';
export { Posts, type Post, type SerializedPost } from './post.model';

Server Components

typescript
// app/users/page.tsx
import { Users, type SerializedUser } from '@/lib/models';
import { UserCard } from '@/components/UserCard';

export default async function UsersPage() {
  const users = await Users.find({ role: 'user' })
    .sort({ createdAt: -1 })
    .limit(20)
    .serialize();

  return (
    <div className="grid gap-4">
      {users.map((user) => (
        <UserCard key={user._id} user={user} />
      ))}
    </div>
  );
}
typescript
// app/users/[id]/page.tsx
import { Users } from '@/lib/models';
import { notFound } from 'next/navigation';

interface Props {
  params: { id: string };
}

export default async function UserPage({ params }: Props) {
  const user = await Users.findById(params.id);

  if (!user) {
    notFound();
  }

  const serialized = user.serialize();

  return (
    <div>
      <h1>{serialized.name}</h1>
      <p>{serialized.email}</p>
      <p>Joined: {new Date(serialized.createdAt).toLocaleDateString()}</p>
    </div>
  );
}

Client Components

typescript
// components/UserCard.tsx
'use client';

import type { SerializedUser } from '@/lib/models';

interface Props {
  user: SerializedUser;
}

export function UserCard({ user }: Props) {
  return (
    <div className="p-4 border rounded">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {user.avatar && <img src={user.avatar} alt={user.name} />}
      <small>
        Joined {new Date(user.createdAt).toLocaleDateString()}
      </small>
    </div>
  );
}

Server Actions

typescript
// app/actions/user.actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { Users, type SerializedUser } from '@/lib/models';
import { MonchValidationError } from '@codician-team/monch';

export async function getUsers(): Promise<SerializedUser[]> {
  return Users.find()
    .sort({ createdAt: -1 })
    .serialize();
}

export async function getUser(id: string): Promise<SerializedUser | null> {
  const user = await Users.findById(id);
  return user?.serialize() ?? null;
}

export async function createUser(formData: FormData) {
  try {
    const user = await Users.insertOne({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });

    revalidatePath('/users');
    return { success: true, user: user.serialize() };
  } catch (error) {
    if (error instanceof MonchValidationError) {
      return { success: false, errors: error.toFormErrors() };
    }
    throw error;
  }
}

export async function updateUser(id: string, formData: FormData) {
  try {
    const user = await Users.updateOne(
      { _id: id },
      {
        $set: {
          name: formData.get('name') as string,
          email: formData.get('email') as string,
        },
      }
    );

    revalidatePath('/users');
    revalidatePath(`/users/${id}`);
    return { success: true, user: user?.serialize() ?? null };
  } catch (error) {
    if (error instanceof MonchValidationError) {
      return { success: false, errors: error.toFormErrors() };
    }
    throw error;
  }
}

export async function deleteUser(id: string) {
  await Users.deleteOne({ _id: id });
  revalidatePath('/users');
  return { success: true };
}

Form with Validation

typescript
// components/UserForm.tsx
'use client';

import { useState } from 'react';
import { createUser } from '@/app/actions/user.actions';

export function UserForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function handleSubmit(formData: FormData) {
    setPending(true);
    setErrors({});

    const result = await createUser(formData);

    if (!result.success) {
      setErrors(result.errors);
    }

    setPending(false);
  }

  return (
    <form action={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          required
          className={errors.name ? 'border-red-500' : ''}
        />
        {errors.name && (
          <p className="text-red-500 text-sm">{errors.name}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className={errors.email ? 'border-red-500' : ''}
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email}</p>
        )}
      </div>

      {errors._root && (
        <p className="text-red-500">{errors._root}</p>
      )}

      <button type="submit" disabled={pending}>
        {pending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

API Routes

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Users } from '@/lib/models';
import { MonchValidationError } from '@codician-team/monch';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = Number(searchParams.get('page')) || 1;
  const limit = Number(searchParams.get('limit')) || 20;

  const result = await Users.find()
    .sort({ createdAt: -1 })
    .paginate({ page, limit });

  return NextResponse.json({
    users: result.data.map(u => u.serialize()),
    pagination: result.pagination,
  });
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const user = await Users.insertOne(body);

    return NextResponse.json(user.serialize(), { status: 201 });
  } catch (error) {
    if (error instanceof MonchValidationError) {
      return NextResponse.json(
        { errors: error.toFormErrors() },
        { status: 400 }
      );
    }
    throw error;
  }
}

Pagination Component

typescript
// components/Pagination.tsx
'use client';

import Link from 'next/link';

interface Props {
  page: number;
  totalPages: number;
  hasNext: boolean;
  hasPrev: boolean;
  baseUrl: string;
}

export function Pagination({ page, totalPages, hasNext, hasPrev, baseUrl }: Props) {
  return (
    <nav className="flex items-center gap-4">
      {hasPrev ? (
        <Link href={`${baseUrl}?page=${page - 1}`}>Previous</Link>
      ) : (
        <span className="text-gray-400">Previous</span>
      )}

      <span>Page {page} of {totalPages}</span>

      {hasNext ? (
        <Link href={`${baseUrl}?page=${page + 1}`}>Next</Link>
      ) : (
        <span className="text-gray-400">Next</span>
      )}
    </nav>
  );
}

Environment Setup

bash
# .env.local
MONGODB_URI=mongodb://localhost:27017/myapp

Debug Mode in Development

typescript
// lib/db.ts
import { setDebug } from '@codician-team/monch';

if (process.env.NODE_ENV === 'development') {
  setDebug(true);
}

Released under the MIT License.