Архитектурные требования
Для создания единой системы, обслуживающей три типа интерфейсов, необходимо решить задачу мульти‑тенантности без развертывания отдельных приложений. В рамках проекта выделены три логических зоны:
- Маркетинговый сайт –
dokly.co. Обычное публичное представление продукта. - Панель управления –
app.dokly.co. Защищённые роуты, аутентификация, редактор контента. - Документационные сайты пользователей –
*.dokly.co. Каждый клиент получает отдельный субдомен (например,acme.dokly.co) для своих документов. - Пользовательские домены –
example.com. Премиум‑функция, позволяющая привязать собственный домен к документации.
Все эти зоны реализованы в одном репозитории и развертываются в едином контейнере, что упрощает CI/CD и сокращает затраты на инфраструктуру.
Маршрутизация через Middleware
Ключевым элементом решения стала возможность перехватывать каждый запрос до того, как он попадёт в роутер страниц. В Next.js 15 это реализовано через middleware. Пример кода, отвечающий за распределение трафика, выглядит следующим образом:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const hostname = request.headers.get("host") ?? "";
const pathname = request.nextUrl.pathname;
const baseDomain = "dokly.co";
// Пропускаем статику и API‑эндпоинты
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Маркетинг
if (hostname === baseDomain || hostname === `www.${baseDomain}`) {
return NextResponse.next(); // app/(marketing)/
}
// Панель управления
if (hostname === `app.${baseDomain}`) {
return NextResponse.rewrite(
new URL(`/dashboard${pathname}`, request.url)
);
}
// Пользовательские субдомены
if (hostname.endsWith(`.${baseDomain}`) && hostname !== `app.${baseDomain}`) {
const subdomain = hostname.replace(`.${baseDomain}`, "");
return NextResponse.rewrite(
new URL(`/sites/${subdomain}${pathname}`, request.url)
);
}
// Пользовательские домены – проверка в базе
const response = NextResponse.rewrite(
new URL(`/sites/_custom${pathname}`, request.url)
);
response.headers.set("x-custom-domain", hostname);
return response;
}
Middleware позволяет переписывать URL‑адреса в зависимости от хоста, тем самым направляя запросы в нужные роуты без создания отдельных приложений. При этом все запросы к статическим ресурсам (/_next/*, /api/*) проходят без вмешательства, что сохраняет производительность.
Структура проекта
Для поддержки нескольких контекстов в рамках единого app‑router использованы route groups. Примерная файловая иерархия:
/app
/(marketing) // публичный сайт dokly.co
page.tsx
layout.tsx
/(dashboard) // панель управления app.dokly.co
page.tsx
layout.tsx
/settings
/profile
/sites
/[tenant] // динамический роут для *.dokly.co
page.mdx
layout.tsx
/_custom // обработка пользовательских доменов
page.mdx
layout.tsx
/api
/auth
/documents
/lib
/db.ts // функции доступа к базе
/mdx.ts // парсер и рендерер MDX
/components
/Header.tsx
/Sidebar.tsx
/Editor.tsx
Директория /(marketing) и /(dashboard) изолируют стили и логику, предотвращая конфликты между зонами. Динамический роут [tenant] позволяет автоматически подхватывать любой субдомен без предварительной регистрации маршрутов.
Работа с MDX и ISR
Документация хранится в формате MDX, что даёт возможность включать в тексты React‑компоненты (таблицы, диаграммы, интерактивные примеры). Для рендера MDX используется пакет next-mdx-remote, позволяющий загружать контент на сервере и передавать уже отрендеренный HTML‑фрагмент клиенту.
// lib/mdx.ts
import { serialize } from "next-mdx-remote/serialize";
import remarkGfm from "remark-gfm";
export async function getMdxSource(source: string) {
return await serialize(source, {
mdxOptions: {
remarkPlugins: [remarkGfm],
},
});
}
Каждая страница документации генерируется с помощью Incremental Static Regeneration (ISR). При запросе Next.js проверяет тайм‑стамп revalidate и, если срок истёк, запускает пересборку только изменившихся страниц, не затрагивая остальные. Это обеспечивает быстрый отклик и актуальность контента без полной пере‑деплойки.
export const revalidate = 60; // обновлять каждые 60 секунд
Для пользовательских доменов реализована дополнительная проверка в базе: при первом запросе к example.com система ищет соответствие в таблице domains, после чего генерирует кешированную запись в KV‑хранилище, чтобы ускорить последующие запросы.
Проблемы и решения
1. Конфликты субдоменов и API‑путей
Изначально роутинг перехватывал запросы к /api и /static, приводя к ошибкам 404. Решение – добавить в middleware проверку на префиксы /_next и /api, а также исключить запросы, содержащие точку (.) в пути, что обычно указывает на статический файл.
2. Переполнение кеша ISR
При большом количестве клиентов количество статических страниц резко возрастало, вызывая превышение лимита Vercel. Было внедрено on‑demand revalidation через webhook, который триггерит пересборку только при изменении конкретного документа, а не по таймеру.
3. Поддержка пользовательских SSL‑сертификатов
Для кастомных доменов требовалась автоматическая генерация сертификатов Let’s Encrypt. Интеграция с Vercel Edge Config позволила хранить сертификаты в KV‑хранилище и обновлять их без простоя.
4. SEO‑оптимизация субдоменов
По умолчанию Next.js генерировал одинаковый robots.txt для всех субдоменов, что мешало индексации отдельных сайтов. Был добавлен отдельный файл robots.txt в каждую папку [tenant], генерируемый на лету с учётом hostname.
Развёртывание и масштабирование
Проект упакован в Docker‑образ, где next start запускает приложение в режиме standalone. Для CI/CD используется GitHub Actions, которые автоматически собирают образ и пушат его в Docker Hub. На этапе деплоя в Vercel (или аналогичную платформу) задаётся переменная окружения BASE_DOMAIN=dokly.co, позволяющая middleware корректно определять базовый домен независимо от среды.
Для горизонтального масштабирования применяется Edge Runtime: middleware исполняется в Edge‑сетях, минимизируя задержку при определении хоста. База данных (PostgreSQL) отделена от статических ресурсов, а кеширование запросов к таблице domains реализовано через Redis, что снижает нагрузку на основной DB.
В результате получилась единая, легко поддерживаемая платформа, способная обслуживать сотни одновременно активных субдоменов, предоставлять гибкие возможности кастомизации и обеспечивать быстрый отклик благодаря ISR и Edge‑вычислениям. Такой подход демонстрирует, как современные возможности Next.js 15 позволяют решать задачи мульти‑тенантности без излишней инфраструктурной сложности.