У компании несколько сайтов, и на каждом есть форма «Оставьте заявку». Менеджерам удобнее, когда все заявки приходят в одно место и в одном формате. Поэтому я сделал один серверный обработчик и подключил к нему формы всех сайтов. Разбираю, из чего он состоит.
Серверный маршрут 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 с пустыми значениями.
Итог
Все заявки компании приходят в одно место и в одном формате, не теряются при сбоях мессенджера, а подключить новый сайт — значит добавить его домен в список разрешённых. Тот же подход я использую и на этом сайте-портфолио.