dphil.ru
Сайт, на который вы сейчас смотрите
Роль
Creator, Full-Stack Engineer, DevOps
Год
2026
Стек
Next.js 16React 19TypeScriptTailwind CSS 4TanStack QueryGSAP 3View TransitionsReact Hook FormStrapi 5.5PostgreSQL 17S3 StorageDockerDokploy

Задача
Создать портфолио-сайт, который служит не просто «визиткой на скорую руку», а живой демонстрацией инженерной зрелости. Требовалось реализовать продакшн-решения: отказоустойчивую инфраструктуру, глубокую локализацию, продвинутые анимации без ущерба для UX и строгую архитектуру данных.
Этапы
Архитектурное проектирование и дизайн данных
- Схема данных: Разработал реляционную модель в Strapi 5.5 и Postgres 17. Сразу заложил гибридную локализацию: технические поля (slug, ссылки, стек) сделали общими, а контент — переводимым, что исключило рассинхрон данных.
- Архитектура фронтенда: Выстроил скелет приложения по методологии Feature-Sliced Design (FSD). Для каждого среза определил строгие границы: index.ts для безопасного клиентского экспорта и server.ts для серверного кода, чтобы изолировать транспортный слой Strapi от клиентского бандла.
DevOps-фундамент и преодоление эфемерности контейнеров
- Инфраструктура: Развернул Docker-окружение под управлением self-hosted оркестратора Dokploy.
- Медиа-слой: Поскольку Docker-контейнеры эфемерны и затирают локальные данные при каждом деплое, я на первом же этапе подключил S3-совместимое объектное хранилище и CDN. Для удобства локальной разработки настроил graceful fallback на жесткий диск, если переменные облака не заданы.
Разработка ядра, серверный рендеринг и чистый i18n
- Рендеринг: Инициализировал Next.js 16 и React 19. Запросы к CMS перенес на сервер (RSC + Route Handlers), обеспечив мгновенную отдачу готового HTML без «мигающих» состояний загрузки.
- Оптимизация локализации: Настроил i18next так, чтобы он работал строго на сервере с кэшированием инстанса per-locale. Клиент получает уже готовые строки как пропсы — это сэкономило кучу JS в браузере и избавило от лишних клиентских провайдеров.
Анимационный инжиниринг и синхронизация API
- Интеграция: Внедрил GSAP 3 для микроанимаций и экспериментальный View Transitions API в Next.js для плавных переходов между страницами.
- Инженерный вызов: Столкнулся с багом «вспышки контента» (FOUC), когда React-коммиты при догрузке ленивых чанков накладывались на бегущую GSAP-интро. Решил проблему на уровне структуры (через key-ремаунт компонента <ViewTransition> с update="none"), сделав анимации устойчивыми к React StrictMode.
Автоматизация кэширования, UI-полировка и релиз
- On-Demand ISR: Связал Strapi и Next.js через вебхуки. При публикации контента бэкенд стучится в роут фронтенда и точечно инвалидирует тэги кэша конкретного проекта — данные обновляются на лету без пересборки всего сайта.
- UI-финализация: Настроил дизайн-токены в Tailwind 4 (@theme inline), внедрив инлайновый скрипт для переключения тем без визуального дребезга. Настроил CI/CD через сборку образов на Docker Hub.
Процесс
Технологический стек
| Слой | Технологии |
|---|---|
| Фронтенд | Next.js 16 (App Router), React 19, TypeScript, Tailwind CSS 4 |
| Данные на клиенте | TanStack Query |
| Анимации | GSAP 3 + @gsap/react, View Transitions API (эксперимент. фича Next 16) |
| Формы | React Hook Form + Zod |
| Контент | react-markdown + remark-gfm, yet-another-react-lightbox, react-photo-album |
| i18n | i18next / react-i18next, 3 языка: RU / EN / DE |
| Бэкенд | Strapi 5.5 (headless CMS) на Node, PostgreSQL 17 |
| Хранилище медиа | S3-совместимое объектное хранилище + CDN |
| Инфраструктура | Docker, VPS, Dokploy, раздельные git-репозитории frontend/backend |
Фронтенд: что за фишками
Архитектура — Feature-Sliced Design
Код разложен по слоям app → views → widgets → features → entities → shared, а не свален в components/. У каждого среза свой barrel (index.ts) для клиент-безопасного экспорта и отдельный server.ts для серверного кода — так клиентский бандл физически не может утащить за собой транспортный слой Strapi. Бизнес-логика (инфинити-скролл, контакт-форма, сборка данных страницы) вынесена в хуки и view-api сервисы, а не размазана по компонентам. Это архитектура, которую можно спокойно масштабировать в команде — не «портфолио-костыль».
// entities/project/index.ts — client-safe barrel
export { projectTags, type ProjectTag } from './config/tags';
export { fetchProjectsPage, PROJECTS_PAGE_SIZE } from './api/client';
export { useInfiniteProjects } from './api/use-infinite-projects';
export { ProjectCard, type ProjectCardProps } from './ui/ProjectCard';
// entities/project/server.ts — server-only barrel, imported
// only by RSC/route handlers, never reaches the client bundle
export {
getProject,
getProjects,
getProjectsCount,
getProjectSlugs,
} from './api/server';
Server Components + Server Route Handlers
Данные из Strapi запрашиваются на сервере (RSC + route handlers), клиенту прилетает уже готовый HTML и минимальный JS — быстрый первый рендер, SEO из коробки, никаких «мигающих» состояний загрузки на главной.
Тема и токены без FOUC
Вся стилистика (цвета, отступы, шрифты) заведена в CSS-токены (Tailwind 4 @theme inline), переключение темы работает без «мигания» неправильной темы при загрузке: инлайновый скрипт применяет .dark до первой отрисовки (через useServerInsertedHTML), плюс <noscript>-фолбэк и уважение prefers-reduced-motion.
Мультиязычность на сервере
i18next работает только на сервере — инстанс кэшируется per-locale, клиентские компоненты получают уже переведённые строки как пропсы, без лишнего провайдера и лишнего JS в браузере. Локаль определяется по приоритету: кука NEXT_LOCALE → заголовок Accept-Language (через negotiator + intl-localematcher) → дефолт en. Локализовано всё, включая PDF-резюме: в зависимости от выбранной локали отдаётся русская или англоязычная версия CV (для немецкой локали — английский вариант).
GSAP-анимации и борьба со «вспышкой» контента
Кастомный анимированный курсор, интро-анимации текста (SplitText), плавные переходы между страницами через экспериментальный View Transitions API Next.js.
Отдельная история — баг с «вспышкой» контента при первой загрузке (heading появляется → пропадает → анимируется заново). Причина оказалась нетривиальной: React в экспериментальном режиме View Transition вызывает document.startViewTransition не только при навигации, но и при любом коммите с transition (в частности — при догрузке ленивого клиентского чанка сразу после гидратации), и снэпшот-анимация страницы накладывалась поверх ещё бегущей GSAP-интро. Финальное решение — чёткое разделение зон ответственности: первая загрузка страницы принадлежит GSAP, переходы между роутами — View Transitions API, всё остальное не анимируется вообще. Реализовано структурно (через key-ремаунт <ViewTransition> с update="none" для внутренних коммитов), а не костылём на классах/таймерах — устойчиво к React StrictMode. Такие кейсы — хороший маркер того, что человек умеет не просто использовать экспериментальные API, а разбираться в их внутренней механике, когда документации ещё почти нет.
// shared/lib/vt/PageTransition.tsx
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
// key={pathname} → route change unmounts/remounts this boundary,
// activating the "vtpage" transition pair (vt-page-out/in in CSS).
// update="none" → any other commit (lazy chunks, data refresh)
// is skipped by React entirely — no snapshot fights the running GSAP intro.
<ViewTransition key={pathname} name="vtpage" update="none">
<div>{children}</div>
</ViewTransition>
);
}
Галерея и медиа
yet-another-react-lightbox (React 19-совместимая) + react-photo-album для masonry-раскладки скриншотов проектов — портретные и альбомные кадры не ломают высоту строки. Markdown-контент из CMS рендерится через react-markdown + remark-gfm с кастомным рендерером <img>, который открывает те же изображения в общем лайтбоксе.
Мокапы без единого лишнего файла
Скриншоты проектов, где нет реальных ассетов, — это CSS-отрисованные мокапы браузера/телефона (детерминированный выбор варианта по slug проекта), а не заглушки-картинки. Экономит вес страницы и решает проблему отсутствующих ассетов для клиентских проектов под NDA.
Формы
React Hook Form + Zod-валидация на контакт-форме — типобезопасная схема валидации, которая расшаривается между клиентской и потенциально серверной проверкой.
Бэкенд: Strapi + Postgres
Строгая модель данных
Коллекции: проекты, теги, «о себе» (singleton), заявки с контакт-формы — со связями между ними, а не плоские таблицы. Локализация полей продумана избирательно: тексты (описание, задача, процесс, результат) — per-locale, а служебные поля (slug, год, стек, ссылки, обложка, порядок тегов) — общие для всех локалей. Это исключает рассинхрон данных между языковыми версиями одного проекта.
Медиа — в S3, а не на диске VPS
Загрузка файлов настроена через @strapi/provider-upload-aws-s3 на S3-совместимое хранилище (Ceph RGW) с раздачей через CDN. Причина не просто «модно», а практическая: бэкенд крутится в Docker-контейнере, который пересобирается при каждом деплое — локальный диск контейнера эфемерный, и без внешнего хранилища все загруженные медиафайлы пропадали бы при каждом релизе. Настроено с graceful fallback на локальный диск, если S3-переменные окружения не заданы — можно спокойно разрабатывать локально без облачного бакета.
Publish без пересборки сайта — on-demand ISR
Strapi при публикации/обновлении контента бьёт вебхуком в /api/revalidate на фронтенде, который точечно инвалидирует Next.js cache tags (projects, project:<slug>, about) — без полного редеплоя и без инвалидации всего сайта разом. Обновление одного кейса не сбрасывает кэш остальных.
// app/api/revalidate/route.ts
switch (body.model) {
case 'project':
tags.add('projects');
if (body.entry?.slug) tags.add(`project:${body.entry.slug}`);
break;
// ...
}
// 'max' = stale-while-revalidate: посетитель мгновенно получает закэшированную
// страницу, свежая версия строится в фоне; если в момент публикации упадёт
// CMS, старая страница просто останется висеть, а не отдаст посетителю 500.
for (const tag of tags) revalidateTag(tag, 'max');
Осознанный выбор в пользу «на один reload позже, но никогда не 500» вместо блокирующей мгновенной инвалидации — типичный компромисс, который выглядит по-разному в демо-проекте и в проекте, рассчитанном на реальный аптайм.
SEO-метаданные
Title/description управляются из CMS, но поддерживают динамические токены {years} и {projects} — количество лет опыта и число проектов пересчитываются на лету (год старта карьеры + актуальный счётчик из CMS), так что тексты не «протухают» и не требуют ручного обновления каждый январь.
// shared/lib/seo.ts
export function applyMetaTokens(text: string, projectsTotal: number): string {
const years = new Date().getFullYear() - site.careerStartYear;
const projects = projectsTotal;
return text.replaceAll('{years}', String(years)).replaceAll('{projects}', String(projects));
}
Текст в CMS пишется как «{years}+ лет коммерческой разработки» один раз — и остаётся верным вечно, а не до следующего января.
Уведомления и обратная связь
Заполнение контакт-формы триггерит письмо на почту администратора (SMTP через env, с тем же паттерном graceful fallback — без настроенных кредов форма просто логирует, а не падает).
Планы
- Блог — та же модель контента, что и у проектов (Strapi collection + on-demand ISR), локализация RU/EN/DE. Работает на позиционирование как эксперта и добавляет сайту органический SEO-трафик, а не только карточку «обо мне».
- Тесты — сейчас за корректность отвечают TypeScript + Zod-схемы на границах данных, но нет автоматизированного покрытия поведения: e2e (Playwright) на ключевые сценарии — переключение темы/языка, контакт-форма, навигация с View Transitions — и unit-тесты на бизнес-логику в
entities/features. Естественное продолжение уже выстроенной FSD-архитектуры, где логика вынесена из компонентов и потому тестируема изолированно.
Что это в сумме показывает
- Full-stack владение: от инфраструктуры (Docker, S3, CDN, вебхуки) до анимационной полировки в браузере — без пробелов посередине.
- Продакшн-мышление, а не демо-код: кэш-инвалидация, эфемерность контейнеров, graceful fallback-и для локальной разработки, устойчивость к React StrictMode — вещи, которые вылезают только на реальных проектах.
- Готовность работать с новым/экспериментальным стеком (Next.js 16, React 19, View Transitions API) и разбираться в их внутренней механике, когда официальной документации ещё недостаточно.
- Дизайн-дисциплина: токенизированная стилистика, продуманная типографика (Unbounded + Exo 2, с поддержкой кириллицы), минимализм без потери деталей.
- Локализация не для галочки: 3 языка на уровне контента и метаданных, включая раздельные PDF резюме под аудиторию.
Результаты
языка с автоопределением из браузера
страниц рендерится на сервере
для переводов на клиенте
CLS