Next.js с MongoDB и Mongoose

16 октября 2026
Автор

Антон Ларичев

Введение

MongoDB — документоориентированная база данных, которая хорошо сочетается с Next.js благодаря гибкой схеме и нативной поддержке JSON-подобных структур. Mongoose добавляет поверх нативного драйвера удобный слой схем, валидации и методов, что делает работу с данными предсказуемой и типизированной.

В этой статье разберём, как правильно подключить MongoDB к Next.js, создать переиспользуемое соединение, описать модели и реализовать CRUD-операции через App Router API Routes.

Курсы PurpleSchool

Учитесь у практикующих разработчиков — без воды

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Установка зависимостей

Для работы с MongoDB через Mongoose потребуются два пакета:

npm install mongoose

Монговый нативный драйвер (mongodb) устанавливается автоматически как зависимость Mongoose, так что отдельно его ставить не нужно.

Настройка переменных окружения

Строка подключения к MongoDB хранится в переменной окружения. Создайте файл .env.local в корне проекта:

MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/mydb?retryWrites=true&w=majority

Если вы используете локальный MongoDB:

MONGODB_URI=mongodb://localhost:27017/mydb

Next.js автоматически загружает .env.local — дополнительных пакетов типа dotenv не нужно. Переменная без префикса NEXT_PUBLIC_ доступна только на сервере, что правильно для строк подключения к базе данных.

Переиспользуемое соединение с MongoDB

Next.js работает в serverless-режиме: каждый API-запрос может выполняться в отдельном контексте. Если открывать новое соединение при каждом вызове, быстро исчерпается лимит подключений MongoDB.

Решение — кэшировать соединение в глобальном объекте Node.js, который сохраняется между горячими перезагрузками в режиме разработки и переиспользуется в рамках одного процесса.

Создайте файл lib/mongoose.ts:

import mongoose, { Mongoose } from 'mongoose';

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
  throw new Error('MONGODB_URI is not defined in environment variables');
}

interface MongooseCache {
  conn: Mongoose | null;
  promise: Promise<Mongoose> | null;
}

declare global {
  var mongoose: MongooseCache | undefined;
}

const cached: MongooseCache = global.mongoose ?? { conn: null, promise: null };
global.mongoose = cached;

export async function connectToDatabase(): Promise<Mongoose> {
  if (cached.conn) {
    return cached.conn;
  }

  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI!, {
      bufferCommands: false,
    });
  }

  cached.conn = await cached.promise;
  return cached.conn;
}

Логика проста: при первом вызове функция создаёт соединение и сохраняет промис в cached.promise. Все последующие вызовы получают уже готовое соединение из cached.conn. Это предотвращает создание множества параллельных подключений.

Параметр bufferCommands: false отключает внутреннюю буферизацию команд Mongoose. Без него запросы к базе могут молча «висеть» вместо того, чтобы выбросить ошибку при отсутствии соединения.

Определение моделей Mongoose

Модели описывают структуру документов в коллекции. Их принято хранить в папке models/.

Простая модель пользователя

Создайте файл models/User.ts:

import mongoose, { Schema, Document, Model } from 'mongoose';

export interface IUser extends Document {
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

const UserSchema = new Schema<IUser>(
  {
    name: {
      type: String,
      required: [true, 'Имя обязательно'],
      trim: true,
      maxlength: [100, 'Имя не может быть длиннее 100 символов'],
    },
    email: {
      type: String,
      required: [true, 'Email обязателен'],
      unique: true,
      lowercase: true,
      trim: true,
      match: [/^\S+@\S+\.\S+$/, 'Некорректный формат email'],
    },
  },
  {
    timestamps: true,
  }
);

// Защита от повторной регистрации модели при горячей перезагрузке
const User: Model<IUser> =
  mongoose.models.User ?? mongoose.model<IUser>('User', UserSchema);

export default User;

Конструкция mongoose.models.User ?? mongoose.model(...) — важный паттерн для Next.js. В режиме разработки модули могут перезагружаться, и Mongoose выбросит ошибку, если попытаться зарегистрировать модель с уже существующим именем. Проверка mongoose.models.User предотвращает это.

Модель со связями

Создайте файл models/Post.ts:

import mongoose, { Schema, Document, Model, Types } from 'mongoose';

export interface IPost extends Document {
  title: string;
  content: string;
  author: Types.ObjectId;
  tags: string[];
  published: boolean;
  createdAt: Date;
  updatedAt: Date;
}

const PostSchema = new Schema<IPost>(
  {
    title: {
      type: String,
      required: [true, 'Заголовок обязателен'],
      trim: true,
      maxlength: [200, 'Заголовок не может быть длиннее 200 символов'],
    },
    content: {
      type: String,
      required: [true, 'Содержимое обязательно'],
    },
    author: {
      type: Schema.Types.ObjectId,
      ref: 'User',
      required: true,
    },
    tags: [{ type: String, trim: true }],
    published: {
      type: Boolean,
      default: false,
    },
  },
  {
    timestamps: true,
  }
);

const Post: Model<IPost> =
  mongoose.models.Post ?? mongoose.model<IPost>('Post', PostSchema);

export default Post;

API Routes в App Router

В App Router API-маршруты находятся в app/api/. Каждый маршрут — это файл route.ts с именованными экспортами для HTTP-методов.

Получение и создание пользователей

Создайте файл app/api/users/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongoose';
import User from '@/models/User';
import { MongoServerError } from 'mongodb';

export async function GET() {
  try {
    await connectToDatabase();

    const users = await User.find({}).select('-__v').lean();

    return NextResponse.json({ users });
  } catch (error) {
    console.error('GET /api/users error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

export async function POST(request: NextRequest) {
  try {
    await connectToDatabase();

    const body = await request.json();
    const { name, email } = body;

    if (!name || !email) {
      return NextResponse.json(
        { error: 'Имя и email обязательны' },
        { status: 400 }
      );
    }

    const user = await User.create({ name, email });

    return NextResponse.json({ user }, { status: 201 });
  } catch (error) {
    if (error instanceof MongoServerError && error.code === 11000) {
      return NextResponse.json(
        { error: 'Пользователь с таким email уже существует' },
        { status: 409 }
      );
    }

    if (error instanceof Error && error.name === 'ValidationError') {
      return NextResponse.json({ error: error.message }, { status: 422 });
    }

    console.error('POST /api/users error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

Метод .lean() возвращает обычные JavaScript-объекты вместо Mongoose-документов, что заметно ускоряет запросы только на чтение.

Операции с конкретным пользователем

Создайте файл app/api/users/[id]/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import mongoose from 'mongoose';
import { connectToDatabase } from '@/lib/mongoose';
import User from '@/models/User';

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

function isValidObjectId(id: string): boolean {
  return mongoose.Types.ObjectId.isValid(id);
}

export async function GET(_request: NextRequest, { params }: RouteParams) {
  if (!isValidObjectId(params.id)) {
    return NextResponse.json({ error: 'Некорректный ID' }, { status: 400 });
  }

  try {
    await connectToDatabase();

    const user = await User.findById(params.id).select('-__v').lean();

    if (!user) {
      return NextResponse.json(
        { error: 'Пользователь не найден' },
        { status: 404 }
      );
    }

    return NextResponse.json({ user });
  } catch (error) {
    console.error('GET /api/users/[id] error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

export async function PATCH(request: NextRequest, { params }: RouteParams) {
  if (!isValidObjectId(params.id)) {
    return NextResponse.json({ error: 'Некорректный ID' }, { status: 400 });
  }

  try {
    await connectToDatabase();

    const body = await request.json();
    const allowedFields = ['name', 'email'];
    const update = Object.fromEntries(
      Object.entries(body).filter(([key]) => allowedFields.includes(key))
    );

    const user = await User.findByIdAndUpdate(
      params.id,
      { $set: update },
      { new: true, runValidators: true }
    ).select('-__v');

    if (!user) {
      return NextResponse.json(
        { error: 'Пользователь не найден' },
        { status: 404 }
      );
    }

    return NextResponse.json({ user });
  } catch (error) {
    if (error instanceof Error && error.name === 'ValidationError') {
      return NextResponse.json({ error: error.message }, { status: 422 });
    }

    console.error('PATCH /api/users/[id] error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

export async function DELETE(_request: NextRequest, { params }: RouteParams) {
  if (!isValidObjectId(params.id)) {
    return NextResponse.json({ error: 'Некорректный ID' }, { status: 400 });
  }

  try {
    await connectToDatabase();

    const user = await User.findByIdAndDelete(params.id);

    if (!user) {
      return NextResponse.json(
        { error: 'Пользователь не найден' },
        { status: 404 }
      );
    }

    return new NextResponse(null, { status: 204 });
  } catch (error) {
    console.error('DELETE /api/users/[id] error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

Обратите внимание на параметры findByIdAndUpdate: new: true возвращает обновлённый документ (а не исходный), а runValidators: true применяет валидацию схемы при обновлении — по умолчанию она не запускается.

Запросы с фильтрацией и пагинацией

Практически в любом приложении нужна пагинация. Вот пример API для получения постов с фильтрами:

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { connectToDatabase } from '@/lib/mongoose';
import Post from '@/models/Post';

export async function GET(request: NextRequest) {
  try {
    await connectToDatabase();

    const { searchParams } = new URL(request.url);
    const page = Math.max(1, Number(searchParams.get('page') ?? '1'));
    const limit = Math.min(100, Math.max(1, Number(searchParams.get('limit') ?? '10')));
    const skip = (page - 1) * limit;
    const tag = searchParams.get('tag');
    const published = searchParams.get('published');

    const filter: Record<string, unknown> = {};

    if (tag) {
      filter.tags = tag;
    }

    if (published !== null) {
      filter.published = published === 'true';
    }

    const [posts, total] = await Promise.all([
      Post.find(filter)
        .populate('author', 'name email')
        .sort({ createdAt: -1 })
        .skip(skip)
        .limit(limit)
        .lean(),
      Post.countDocuments(filter),
    ]);

    return NextResponse.json({
      posts,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    });
  } catch (error) {
    console.error('GET /api/posts error:', error);
    return NextResponse.json(
      { error: 'Внутренняя ошибка сервера' },
      { status: 500 }
    );
  }
}

Запрос выполняется параллельно через Promise.all: одновременно получаем данные и считаем общее количество документов. Это вдвое быстрее последовательных запросов.

Использование данных в Server Components

Если данные нужны только для рендеринга страницы, можно обращаться к базе напрямую из Server Components, минуя API-маршрут:

// app/posts/page.tsx
import { connectToDatabase } from '@/lib/mongoose';
import Post from '@/models/Post';

export default async function PostsPage() {
  await connectToDatabase();

  const posts = await Post.find({ published: true })
    .populate('author', 'name')
    .sort({ createdAt: -1 })
    .limit(20)
    .lean();

  return (
    <main>
      <h1>Статьи</h1>
      <ul>
        {posts.map((post) => (
          <li key={String(post._id)}>
            <h2>{post.title}</h2>
          </li>
        ))}
      </ul>
    </main>
  );
}

Server Components выполняются только на сервере, поэтому прямой доступ к базе безопасен. Используйте этот подход для страниц с SSR или ISR, где данные не нужно получать на клиенте.

Типичные ошибки и как их избежать

Не проверять ObjectId перед запросом

MongoDB выбросит исключение, если передать невалидный ObjectId в findById. Всегда проверяйте входящие идентификаторы:

import mongoose from 'mongoose';

if (!mongoose.Types.ObjectId.isValid(id)) {
  return NextResponse.json({ error: 'Некорректный ID' }, { status: 400 });
}

Утечка ошибок базы данных в ответ

Никогда не передавайте объект ошибки напрямую в JSON-ответ — он может содержать чувствительную информацию о структуре базы:

// Плохо
return NextResponse.json({ error }, { status: 500 });

// Хорошо
console.error(error);
return NextResponse.json({ error: 'Внутренняя ошибка сервера' }, { status: 500 });

Игнорировать индексы

MongoDB не создаёт индексы автоматически (кроме _id). Поля, по которым часто фильтруют или сортируют, нужно индексировать явно:

PostSchema.index({ author: 1, createdAt: -1 });
PostSchema.index({ tags: 1 });
PostSchema.index({ published: 1, createdAt: -1 });

Без индексов запросы на большой коллекции будут выполнять полный скан, что резко замедляет приложение.

Не использовать lean() для запросов на чтение

Mongose-документы — тяжёлые объекты с методами, геттерами и прокси. Если результат нужен только для чтения, .lean() возвращает чистые POJO и работает в 3-5 раз быстрее.

Структура проекта

Рекомендуемая организация файлов:

├── app/
│   └── api/
│       ├── users/
│       │   ├── route.ts
│       │   └── [id]/
│       │       └── route.ts
│       └── posts/
│           └── route.ts
├── lib/
│   └── mongoose.ts
├── models/
│   ├── User.ts
│   └── Post.ts
└── .env.local

Такое разделение — соединение в lib/, модели в models/, маршруты в app/api/ — делает код предсказуемым и простым для навигации.

Заключение

Интеграция MongoDB с Next.js через Mongoose сводится к трём шагам: создать переиспользуемое соединение с кэшированием в глобальном объекте, описать модели с типизацией и валидацией, реализовать API-маршруты с корректной обработкой ошибок. Такой подход масштабируется от небольшого прототипа до продакшн-приложения.

Подробнее о построении full-stack приложений на Next.js — в курсе на PurpleSchool: https://purpleschool.ru/course/nextjs?utm_source=knowledgebase&utm_medium=text&utm_campaign=mongodb-mongoose

Стрелочка влевоАутентификация в Next.js с NextAuth.jsDrizzle ORM — настройка и использование в Next.jsСтрелочка вправо

Все гайды по Next-js

useSearchParams и usePathname в Next.js App RouterStreaming и Suspense в Next.jsServer Actions в Next.jsrevalidateTag и revalidatePath — инвалидация кэша в Next.jsPrisma ORM в Next.jsnotFound и redirect в серверных компонентах Next.jsNext.js с Supabase — авторизация и база данныхNext.js с GraphQL и Apollo ClientNext.js 15: новые возможности и breaking changesБлог и документация на Next.js с MDXloading.js и error.js в Next.js App RouterАутентификация с JWT в App Router без сторонних библиотекИнтернационализация i18n в Next.js App RoutergenerateStaticParams — статическая генерация динамических маршрутовNext.js cookies и headers в App RouterNext.js с Clerk: аутентификация пользователейNext.js after() — выполнение кода после отправки ответа
Открыть базу знаний

Отправить комментарий