Один обработчик заявок для нескольких сайтов на Nuxt

Как принимать заявки с нескольких сайтов в одном месте: серверный маршрут Nitro, уведомления в Telegram, резервная копия каждой заявки и CORS для соседних доменов.

Разработка~6 мин чтения
Главная страница сайта Construct, на котором работает общий обработчик заявок

У компании несколько сайтов, и на каждом есть форма «Оставьте заявку». Менеджерам удобнее, когда все заявки приходят в одно место и в одном формате. Поэтому я сделал один серверный обработчик и подключил к нему формы всех сайтов. Разбираю, из чего он состоит.

Серверный маршрут Nitro

Nuxt собирается в режиме Node-сервера, поэтому обработчик — это обычный файл в server/api. Он делает четыре вещи: проверяет поля, требует согласие на обработку персональных данных, сохраняет заявку на диск и отправляет уведомление в Telegram.

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  if (!body?.name || !body?.phone) {
    throw createError({ statusCode: 400, statusMessage: 'Имя и телефон' })
  }
  if (!body.terms) {
    throw createError({ statusCode: 400, statusMessage: 'Нужно согласие' })
  }
  await saveBackup(body)      // JSON-файл на диске
  await notifyTelegram(body)  // сообщение менеджерам
  return { ok: true }
})

Заявка не должна теряться

Каждая заявка сначала записывается в JSON-файл и только потом уходит в Telegram. Если мессенджер недоступен, заявка всё равно сохранена, и её можно обработать вручную. Каталог с копиями лежит вне сборки сайта, поэтому переживает любые обновления.

Уведомление уходит сразу нескольким получателям: список chat_id задаётся через запятую в переменной окружения. Если с хостинга API Telegram недоступен напрямую, запросы можно пустить через прокси — это тоже просто настройка, без правки кода.

Приём заявок с соседних сайтов

Формы других сайтов отправляют заявку на чужой домен, и браузер по умолчанию не даст странице прочитать ответ. Обработчик отдаёт заголовок Access-Control-Allow-Origin только для списка своих доменов — чужие сайты отправлять заявки не смогут.

Со стороны формы есть приём, который избавляет от лишнего запроса: тело отправляется как application/x-www-form-urlencoded. Такой запрос браузер считает «простым» и не делает предварительный OPTIONS-запрос, а readBody на сервере разбирает его так же, как JSON.

const response = await fetch(endpoint, {
  method: 'POST',
  body: new URLSearchParams({ name, phone, terms: 'true', source: location.origin })
})
if (!response.ok) {
  // fetch не бросает исключение на 400/500 — проверяем статус сами
  showError('Не удалось отправить заявку. Позвоните нам или попробуйте позже.')
}

Поле source с адресом страницы показывает менеджеру, с какого сайта пришла заявка. А проверка response.ok важна потому, что fetch считает успехом любой ответ сервера, даже с ошибкой: без неё посетитель увидел бы «Спасибо» при неотправленной заявке.

Защита от спама

В версии обработчика для этого сайта-портфолио добавлены ещё два рубежа — их стоит ставить на любую публичную форму:

  • Скрытое поле-ловушка (honeypot): человек его не видит, а бот заполняет — такие заявки молча отбрасываются.
  • Ограничение частоты: не больше нескольких заявок с одного IP за короткое время.
  • Обязательная галочка согласия с политикой обработки персональных данных — это ещё и требование закона.

Грабли: переменные окружения и типы

Nitro при подстановке переменных окружения в runtimeConfig сам угадывает тип значения. chat_id из одних цифр превращается в число, и вызов .split(',') падает с ошибкой — хотя в коде стоит пустая строка по умолчанию, а TypeScript уверен, что там строка. Лечится явным приведением.

const chatIds = String(config.telegramChatIds || '')
  .split(',')
  .map(id => id.trim())
  .filter(Boolean)

Токен бота, chat_id и адрес прокси хранятся только в .env на сервере. В репозитории — лишь .env.example с пустыми значениями.

Итог

Все заявки компании приходят в одно место и в одном формате, не теряются при сбоях мессенджера, а подключить новый сайт — значит добавить его домен в список разрешённых. Тот же подход я использую и на этом сайте-портфолио.

Кейс в портфолиоConstructСмотреть кейс

Есть похожая задача?

Свяжитесь со мной — обсудим ваш проект.