Введение. Нам поступила задача разработать веб-сервис – интерактивную карту с каталогом проверенных коттеджных поселков. Цель проекта – привлечь целевой трафик на сайт определенного агентства недвижимости, рассказывая о поселках и направляя заинтересованных пользователей к риелторам. При этом, помимо рекламной функции, сервис должен быть полезным и информативным: показать расположение поселков на карте, их характеристики, цены и т.д. Задача осложнялась требованиями SEO – требовалось, чтобы страницы хорошо индексировались поисковиками, несмотря на наличие карты и динамических фильтров. Также важно было обойтись без собственного бэкенда для экономии ресурсов и упрощения инфраструктуры. Ниже я расскажу, какие технологии мы выбрали, с какими трудностями столкнулись и как их решили.
Стек технологий и архитектура проекта
Чтобы обеспечить и интерактивность, и SEO-оптимизацию, мы остановились на Next.js – фреймворке для React, поддерживающем серверный рендеринг и статическую генерацию страниц. На момент разработки использовалась современная версия Next (мы начинали на v13, к моменту релиза обновились до Next 15). Вкупе с Next мы применили следующие технологии:
React (актуальная версия на 2025 год, в нашем случае React 19) – UI-библиотека.
TypeScript – для типизации кода, особенно важной при работе с данными поселков.
Material UI (MUI) v6 + Emotion – библиотека компонентов и стилизации для быстрого создания интерфейса (формы фильтров, карточки поселков и т.п.).
styled-components – в проекте использовался и этот инструмент для стилизации некоторых виджетов (исторически сложилось, хотя MUI и Emotion покрывали большинство задач).
Яндекс.Карты API через библиотеку
@pbe/react-yandex-maps
– для отображения интерактивной карты с метками поселков. Мы выбрали Яндекс.Карты, так как проект ориентирован на российский рынок и Яндекс дает актуальные карты местности, а также удобный React-обертку.Без собственного сервера данных – все данные хранятся на стороне фронтенда (в виде statically generated content), поэтому никакого Node.js сервера для API или базы данных мы не поднимали.
Отдельно отмечу, что мы решили использовать статическую генерацию (SSG) возможностей Next.js, собрав сайт как набор статических страниц. При деплое Next генерирует HTML для каждой страницы, что прекрасно с точки зрения SEO: поисковый робот сразу получает готовый контент, а не пустой див для последующего заполнения через React. Однако полностью статический подход потребовал придумать, как хранить и обновлять данные о поселках без бэкенда. Об этом – в следующем разделе.
Хранение данных поселков без бэкенда
Так как серверной базы данных у нас нет, все сведения о коттеджных поселках мы решили хранить прямо в кодовой базе, в виде TypeScript-файлов. Для каждого поселка создан отдельный файл с экспортом объекта, содержащим всю необходимую информацию: название, тип земли, готовность коммуникаций, расстояние от города, диапазон цен, описание, список удобств, фотографии и т.д. Примерно это выглядит так:
// Пример структуры данных одного поселка (Belkino.ts)
export const Belkino: VillageType = {
name: "Белкино",
url: "Belkino",
geoType: "ИЖС",
readiness: "Готов к строительству",
distance: "25 км от КАД на Север",
distanceFilter: 25,
direction: "Север",
coordinates: { lat: 60.0, lon: 30.0 },
prices: { min: 1000000, max: 2000000 },
sizes: { min: 6, max: 12 },
// ... другие поля: описание, преимущества, инфраструктура, ограничения, банки-партнеры ...
description: "Белкино – коттеджный поселок с участками ИЖС ... удобно расположен ...",
advantages: [ "Детская площадка", "Асфальтовые дороги", "Шлагбаум и охрана" ],
photos: [
{ file: "Belkino-1.jpg", alt: "Коттеджный поселок Белкино" },
// ... другие фото ...
],
seoTitle: "Коттеджный поселок Белкино – участки ИЖС от 1 000 000 ₽",
seoDescription: "Белкино – коттеджный поселок ... транспортной доступностью с Санкт-Петербургом.",
schemaData: { /* JSON-LD данные для SEO */ }
};
Каждый такой файл экспортирует константу с данными. Затем в директории всех поселков мы собираем индексный файл, который агрегирует эти экспорты, например:
// assets/villages/index.ts
export { Belkino } from "./Belkino/Belkino";
export { Solnechniy } from "./Solnechniy/Solnechniy";
export { Lesnoe } from "./Lesnoe/Lesnoe";
// ... и т.д. для всех ~50 поселков ...
Таким образом, мы можем импортировать все объекты поселков оптом, используя синтаксис import * as Villages from '@/assets/villages';
. Получив объект с множеством полей, мы берем Object.values(Villages)
чтобы получить массив всех поселков.
Как мы наполняли эти файлы данными? Тут был интересный момент: исходные данные о поселках брались с другого (дружественного) агрегатора, предназначенного для юридических лиц. У нас не было доступа к удобному API или выгрузке, поэтому информацию собирали вручную. Чтобы ускорить процесс, мы использовали связку "скриншот → распознавание → ChatGPT". Сотрудники делали скриншоты страниц агрегатора по каждому поселку, затем с помощью OCR и ChatGPT превращали эти данные в структурированный вид (например, формировали текст для описания, вытаскивали цифры цен, координаты и т.п.). Получив черновые данные, мы приводили их к нужному формату и заполняли TS-файлы. Всего в каталоге на данный момент около 50 поселков.
Такой ручной процесс ввода данных, конечно, не самый элегантный или масштабируемый. Но на старте проекта это оказалось приемлемо: небольшие объемы, отсутствие затрат на разработку админки или парсера, и полный контроль над содержимым (можно поправить текст описания, добавить SEO-теги). Минус в том, что обновлять данные тоже придется руками и выпускать новый билд сайта. Для нашего кейса это не критично: информация о поселках меняется нечасто. Если проект вырастет, можно будет задуматься о выносе данных в CMS или подключении через API.
Реализация интерфейса: список + карта с фильтрами
Изначально мы собирались сделать основной страницей сразу карту с метками – казалось логично сразу показать пользователю карту с поселками. Но мы быстро осознали, что такой подход не SEO-дружественный. Карта сама по себе не содержит текстового контента, поисковику там индексировать нечего. А если карта загружается динамически на клиенте, то бот и вовсе может не увидеть никаких данных о поселках.
Решение: Мы сделали главной страницей список поселков с фильтрами, а карту вынесли либо на отдельную вкладку, либо реализовали как дополнительный виджет на странице, загружающийся после основного контента. Таким образом, при заходе на главную страницу поисковый робот видит перечень поселков с названиями, описанием, ценовым диапазоном и т.д. – полноценный текстовый контент. Это повысит шансы страницы ранжироваться по ключевым словам (названия поселков, характеристики и пр.).
Фильтрация списка поселков
Фильтры позволяют пользователю отобрать поселки по ряду критериев: диапазон цен, размер участка, расстояние от города, направление (север, юг и т.п.), наличие определенных условий (ипотека под сельхозбанк, акции и т.д.). Эти параметры управляются состоянием React (мы используем хук useFilters
для хранения значений фильтров).
Так как данные всех поселков у нас уже загружены (в виде массива объектов), фильтрация сводится к простому array.filter
на клиентской стороне. Например, упрощенно:
// Псевдокод фильтрации поселков по диапазону цены, размеру и расстоянию
const filteredVillages = villages.filter(v => {
const matchesPrice = (!priceMin || v.prices.max >= priceMin)
&& (!priceMax || v.prices.min <= priceMax);
const matchesSize = (!sizeMin || v.sizes.max >= sizeMin)
&& (!sizeMax || v.sizes.min <= sizeMax);
const matchesDistance = (!distMin || v.distanceFilter >= distMin)
&& (!distMax || v.distanceFilter <= distMax);
// ... остальные условия фильтра ...
return matchesPrice && matchesSize && matchesDistance /* ... */;
});
По умолчанию, когда пользователь заходит на главную страницу, фильтры не заданы и отображается полный список всех поселков (что, собственно, и нужно для контента страницы). Если он двигает ползунки цен или отмечает чекбоксы, состояние меняется и список пересчитывается.
Важно, что мы не прячем контент поселков от поисковика: даже если бот зайдет и не применит никаких фильтров (а он не будет, конечно), он получит полный список. Таким образом, каждый поселок хоть где-то, да будет упомянут на индексной странице. Плюс у нас есть отдельные страницы каждого поселка (об этом чуть ниже), которые тоже индексируются.
Интеграция Яндекс.Карт для отображения поселков
Карта – ключевая часть сервиса, ведь визуально показать расположение коттеджных поселков очень полезно для покупателей. Мы встроили карту от Яндекса с помощью пакета @pbe/react-yandex-maps
, который предоставляет удобные React-компоненты: <YMaps>
, <Map>
, <Placemark>
и др.
Отображение меток. Каждому поселку соответствует метка (Placemark
) на карте. Мы сгенерировали массив координат и привязанных данных из наших объектов. Метки выводятся в компоненте <Map>
с помощью метода .map()
по массиву отфильтрованных поселков:
<Map defaultState={{ center: [59.94, 30.19], zoom: 8 }}>
{filteredVillages.map(village => (
<Placemark
key={village.url}
geometry={village.coords}
onClick={() => window.open(`/village/${village.url}`, "_blank")}
options={{
iconLayout: getIconLayout(village.priceText),
iconShape: { type: "Rectangle", coordinates: [[0,0], [100, 25]] },
iconOffset: [-50, -25]
}}
/>
))}
</Map>
Здесь village.coords
– это [latitude, longitude]
поселка, а window.open
по клику просто открывает страницу поселка в новой вкладке.
Кастомный дизайн меток. По умолчанию Яндекс.Метки – это стандартные значки, но нам нужно было отобразить на них диапазон цен поселка, чтобы пользователь сразу видел, за сколько продаются участки. К счастью, API Яндекс.Карт позволяет сделать кастомный макет метки через iconLayout
. Мы использовали templateLayoutFactory
из YMaps, чтобы создать layout на основе HTML-шаблона. Функция getIconLayout
возвращает класс макета с нужным оформлением:
const getIconLayout = (priceText) =>
ymaps?.templateLayoutFactory.createClass(`
<div style="
background-color: #2A6A46; color: #fff;
padding: 4px 8px; border-radius: 6px;
font-family: sans-serif; font-size: 14px; line-height: 1;
position: relative; white-space: nowrap;">
${priceText} млн ₽
<div style="
content: ''; position: absolute;
bottom: -4px; left: 50%; transform: translateX(-50%) rotate(45deg);
width: 8px; height: 8px; background-color: #2A6A46;">
</div>
</div>
`);
Этот макет отображается как зеленая плашка с ценовым диапазоном (например, "1.0 – 2.0 млн ₽") и маленьким треугольником-указателем вниз. Он смотрится аккуратнее стандартных балунов и сразу дает понять разброс цен в поселке. Параметры iconShape
и iconOffset
используются, чтобы клики по метке воспринимались правильно (прямоугольная область нужного размера) и чтобы метка позиционировалась относительно координат (смещение на половину ширины и полной высоты плашки вверх).
Отдельные страницы поселков и SEO
Как уже упоминалось, помимо списка все поселки имеют индивидуальные страницы (URL вида /village/Belkino
). Эти страницы генерируются статически из тех же данных. Мы использовали возможности Next.js для статической генерации маршрутов на основе списка поселков. На каждой странице содержится развернутое описание, фотографии, характеристики, схема JSON-LD (для богатых сниппетов в поиске) и CTA-секция с контактами агентства.
Страницы статей. Кроме того, для роста органического трафика мы добавили раздел со статьями/новостями. Идея в том, чтобы публиковать полезные материалы на смежные темы (например, "Как выбрать коттеджный поселок", "Обзор загородных направлений вокруг Петербурга" и т.д.), которые могут привлечь посетителей из поисковых систем. Эти статьи также статически сгенерированы и содержат ссылки на наш каталог. Таким образом, создан контентный маркетинг вокруг основного каталога.
Проблемы с SSR, клиентскими компонентами и гидрацией
Переход на Next.js 13+ с разделением на Server Components и Client Components принес свои нюансы. Наш список поселков по идее можно рендерить на сервере, ведь данные доступны на этапе билда. Но фильтры и карта – чисто интерактивная логика, завязанная на браузер (особенно карта, которой на сервере просто неоткуда взяться). Мы пометили виджеты фильтрации и карты директивой "use client"
, чтобы Next знал, что их надо рендерить только на клиенте.
Неочевидная сложность возникла в том, как совмещать серверный и клиентский рендер. Например, если бы мы попробовали на сервере вывести что-то вроде "Загружается карта..." или какие-то заглушки для роботов, то при монтировании React на клиенте возникли бы проблемы гидратации – контент не совпал бы. Мы поначалу экспериментировали с таким подходом: отдавать поисковику упрощенный статичный контент, а потом заменять на реальный. Это сработало не лучшим образом: React ругался на несовпадение HTML. В итоге мы пришли к компромиссу: разделили страницу на четко серверную и клиентскую части. Серверная часть (например, верх страницы и список) рендерится как надо и остается стабильной, а в определенном месте мы просто рендерим пустой контейнер для карты, который уже заполняется на клиенте после гидрации. Таким образом, бот не видит карту (ну и ладно), но видит весь остальной контент. А пользователь при загрузке сразу получает список, а через секунду подгружается карта с метками.
Стоит отметить, что Next.js активно развивается, и, возможно, сейчас существуют более изящные решения для таких случаев – например, использование компонента next/dynamic
с опцией ssr: false
для отключения серверного рендера конкретной части. В нашем же случае мы практически вручную контролировали, где у нас серверный контент, а где — чисто клиентский.
Результаты, выводы и планы
Разработка заняла больше времени, чем мы изначально думали. Новая для нас связка Next.js + Яндекс.Карты требовала разобраться с документацией, а отсутствие бэкенда диктовало свои условия (пришлось много автоматизировать вручную, как ни парадоксально звучит). В итоге проект заработал: получился каталог поселков с довольно богатым контентом, интерактивной картой и возможностью фильтрации. Это внутренняя разработка для агентства «Друзья» – через сервис пользователи могут подобрать себе участок, а компания получает лидов.
Что получилось хорошо:
Сайт полностью статический и раздается как набор файлов – это дает отличное время загрузки и безопасность (нечему особо ломаться на сервере).
Поисковые системы могут индексировать ключевые страницы (список и страницы поселков) благодаря тому, что мы сделали их статичными и текстовыми.
Интерфейс удобный: карта с кастомными метками цен, фильтры работают мгновенно без перезагрузки страницы.
Стек на React/Next позволил разделить код на компоненты, легко добавлять новые поселки (просто дописываем TS-файл и ребилдим), а также масштабировать фронтенд-функциональность.
С какими трудностями столкнулись:
Преодоление ограничений без бэкенда: контент нужно обновлять вручную и генерировать проект заново. Для часто обновляемых данных это плохое решение, но для нашей предметной области допустимо. Тем не менее, это не масштабируется, и в будущем, если поселков станет сотни, понадобится иная стратегия (например, вынос данных в отдельный JSON и написание небольшого API или переход на headless CMS).
Особенности Next.js 13+: требовалось понять, какие компоненты должны быть серверными, а какие клиентскими, чтобы и SEO удовлетворить, и ошибки гидрации избегать. Мы наступили на пару граблей, когда ReactDOM выдал предупреждения из-за несовпадения HTML, и потратили время на отладку.
Верстка карты и меток оказалась нетривиальной из-за нюансов CSS внутри
iconLayout
(например, тот же треугольничек-указатель потребовал изобретения небольшого «велосипеда» с повёрнутым квадратом). Однако, в целом кастомизация Яндекс.Меток через HTML – мощный инструмент, хоть и скупой на официальные примеры.
SEO-результаты: Пока рано делать выводы о поисковом трафике – на момент написания статьи сайт недавно запущен и посещаемость еще небольшая. Индексация поисковиками идет, но чтобы попасть в топ выдачи по востребованным запросам, нужно время и, вероятно, дополнительные усилия в плане контент-маркетинга и внешних факторов. Тем не менее, фундамент заложен: страницы оптимизированы (мета-теги, заголовки, SEO-описания, schema.org разметка) и грузятся быстро, а значит шансы на рост трафика есть.
Планы: В ближайших планах доработок больших нет – проект выполнен по текущим требованиям. Возможно, будем расширять каталог по мере появления новых поселков и публиковать статьи в блог. Если увидим, что аудитории не хватает каких-то функций (например, сравнение поселков или калькулятор ипотеки), подумаем о внедрении. В техническом же плане следующий шаг мог бы быть переход на более продвинутую систему управления контентом, если объем данных сильно возрастет.
Заключение
Опыт разработки этого сервиса показал, что даже без выделенного сервера и базы данных можно создать полезный продукт – надо лишь тщательно продумать, как хранить и отдавать данные. Next.js в связке с статической генерацией отлично справился со своей задачей, позволив сочетать SEO-оптимизированный контент и богатый клиентский функционал. Конечно, пришлось принять некоторые компромиссы и побороться с нюансами (SSR vs. CSR, ручной ввод данных), зато мы избежали усложнения архитектуры и дополнительных затрат.
Если вы планируете подобный проект, важно оценить масштаб данных и частоту их обновления. Вполне возможно, что на старте вам не нужен сложный бэкенд – статичного генератора будет достаточно. Но нужно быть готовым пересмотреть подход, когда проект начнет расти. Что касается SEO, главный совет – думайте о том, что увидит поисковый бот: все важное (ключевые слова, названия объектов, описания) должно присутствовать в HTML страниц. Мы едва не совершили ошибку, показав на главной лишь интерактивную карту, и вовремя это исправили, сделав главный экран текстовым.
Наконец, интеграция со сторонними API вроде карт может существенно обогатить ваш сервис, но требует внимания к деталям. Нам удалось настроить кастомные метки и получить желаемый результат на Яндекс.Картах, хотя для этого пришлось полистать форумы и документацию. Зато теперь пользователи видят не просто точки на карте, а наглядную информацию о ценах прямо в плашках меток.
Надеюсь, наш опыт будет полезен тем, кто разрабатывает SPA/SSR приложения с упором на SEO. Проект получился рабочим и уже приносит ценность. Будем рады вопросам и комментариям – расскажем подробнее о реализации отдельных моментов! Всем удачной разработки и высоких позиций в выдаче 😊.