Главная
Все проекты

dphil.ru

Сайт, на который вы сейчас смотрите

Роль

Creator, Full-Stack Engineer, DevOps

Год

2026

Стек

Next.js 16React 19TypeScriptTailwind CSS 4TanStack QueryGSAP 3View TransitionsReact Hook FormStrapi 5.5PostgreSQL 17S3 StorageDockerDokploy

dphil.ru

Задача

Создать портфолио-сайт, который служит не просто «визиткой на скорую руку», а живой демонстрацией инженерной зрелости. Требовалось реализовать продакшн-решения: отказоустойчивую инфраструктуру, глубокую локализацию, продвинутые анимации без ущерба для UX и строгую архитектуру данных.

Этапы

01

Архитектурное проектирование и дизайн данных

  • Схема данных: Разработал реляционную модель в Strapi 5.5 и Postgres 17. Сразу заложил гибридную локализацию: технические поля (slug, ссылки, стек) сделали общими, а контент — переводимым, что исключило рассинхрон данных.
  • Архитектура фронтенда: Выстроил скелет приложения по методологии Feature-Sliced Design (FSD). Для каждого среза определил строгие границы: index.ts для безопасного клиентского экспорта и server.ts для серверного кода, чтобы изолировать транспортный слой Strapi от клиентского бандла.
02

DevOps-фундамент и преодоление эфемерности контейнеров

  • Инфраструктура: Развернул Docker-окружение под управлением self-hosted оркестратора Dokploy.
  • Медиа-слой: Поскольку Docker-контейнеры эфемерны и затирают локальные данные при каждом деплое, я на первом же этапе подключил S3-совместимое объектное хранилище и CDN. Для удобства локальной разработки настроил graceful fallback на жесткий диск, если переменные облака не заданы.
03

Разработка ядра, серверный рендеринг и чистый i18n

  • Рендеринг: Инициализировал Next.js 16 и React 19. Запросы к CMS перенес на сервер (RSC + Route Handlers), обеспечив мгновенную отдачу готового HTML без «мигающих» состояний загрузки.
  • Оптимизация локализации: Настроил i18next так, чтобы он работал строго на сервере с кэшированием инстанса per-locale. Клиент получает уже готовые строки как пропсы — это сэкономило кучу JS в браузере и избавило от лишних клиентских провайдеров.
04

Анимационный инжиниринг и синхронизация API

  • Интеграция: Внедрил GSAP 3 для микроанимаций и экспериментальный View Transitions API в Next.js для плавных переходов между страницами.
  • Инженерный вызов: Столкнулся с багом «вспышки контента» (FOUC), когда React-коммиты при догрузке ленивых чанков накладывались на бегущую GSAP-интро. Решил проблему на уровне структуры (через key-ремаунт компонента <ViewTransition> с update="none"), сделав анимации устойчивыми к React StrictMode.
05

Автоматизация кэширования, 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
i18ni18next / 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 сервисы, а не размазана по компонентам. Это архитектура, которую можно спокойно масштабировать в команде — не «портфолио-костыль».

ts
// 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, а разбираться в их внутренней механике, когда документации ещё почти нет.

tsx
// 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) — без полного редеплоя и без инвалидации всего сайта разом. Обновление одного кейса не сбрасывает кэш остальных.

ts
// 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), так что тексты не «протухают» и не требуют ручного обновления каждый январь.

ts
// 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 резюме под аудиторию.

Результаты

3

языка с автоопределением из браузера

100%

страниц рендерится на сервере

0%

для переводов на клиенте

0

CLS

Экраны