macrulez.ru: переезд с «чистого» Vue на Nuxt и SSR

27.02.2026
macrulez.ru: переезд с «чистого» Vue на Nuxt и SSR

Я давно хотел устроить своему сайту небольшой апгрейд: забрать оттуда SPA-ограничения, добавить нормальный SSR, привести метаданные к порядку и перестать вручную городить API-прокси в клиенте.

В итоге проект с «голым» Vue 3 и Vite переехал на Nuxt (4-я мажорная, по факту Nuxt 3), при этом большая часть существующего кода из src продолжает жить, а не была переписана с нуля. В этом посте — что именно я сделал, как проходила миграция, какие грабли вылезли и какие приёмы сработали.


Зачем вообще это было менять

До переезда сайт выглядел так:

  • SPA на Vue 3 + Vite: одна большая страница, куча секций, кастомный скролл-роутинг, анимации, Canvas/Three.js и т.д.
  • API напрямую из браузера: фронт ходит к macrulez-api.ru сам, без бэкенд-прокси.
  • SEO и превьюшки «как получится»: мета‑теги и Open Graph собирались руками, без настоящего SSR.

Это работало, но у такого подхода есть минусы:

  • Нету SSR — первые секунды пользователю показывается «ничего», пока не приедет бандл и данные.
  • SEO и шаринг страдают — роботы и прегенераторы видят пустой div и минимум контента.
  • Код API интеграций размазан по фронту — типы, схемы ответов и обработка ошибок живут в клиентском коде.
  • Дальше масштабировать неудобно — хочется аккуратно добавлять новые разделы/страницы, не таща за собой SPA-ограничения.

Nuxt решил сразу несколько задач:

  • SSR из коробки, без ручного велосипеда.
  • Единая точка входа в виде server/api/* — можно прятать особенности macrulez-api за удобным интерфейсом.
  • Нормальный runtimeConfig для URL-ов и ключей.
  • Меньше клей-кода вокруг роутинга и метаданных.

От чего я отталкивался

Я изначально не хотел «выбросить всё и переписать с нуля». В репозитории уже были:

  • Компоненты в src/view/components, завязанные на свои стили, миксины, i18n и т.д.
  • Композиционные функции (use-scroll-routing, i18n‑хелперы, работа с API).
  • Собственный порядок секций на главной странице (PageSectionsEnum, конфиг порядка, и т.д.).
  • Много визуальной кастомизации (анимации, loader, декоративные мелочи).

Поэтому цель была такой:

Плавная миграция: подключить Nuxt, а затем постепенно «затянуть» существующий Vue‑код в новую экосистему.


Шаг 1. Подружить Nuxt с существующей структурой

Первое, что сделал — завёл nuxt.config.ts и настроил alias на старый src:

ts Copy
// nuxt.config.ts (фрагмент)
export default defineNuxtConfig({
  ssr: true,

  // Переиспользую существующий код из /src,
  // где во всём проекте уже используется "@/..."
  alias: {
    '@': fileURLToPath(new URL('./src', import.meta.url)),
  },

  css: [
    '@/view/styles/reset.scss',
    '@/view/styles/variables.scss',
    '@/view/styles/main.scss',
    '@/view/pages/index.scss',
  ],
});

Ключевые моменты:

  • Алиас @ смотрит в ./src, как и раньше в Vite‑конфиге. Это позволило не переписывать импорты в компонентах.
  • Глобальные стили (reset, variables, main, стили страницы) просто подключены в Nuxt на уровне css.
  • SSR включён сразу (ssr: true), но дальше важно было сделать так, чтобы гидрация не взорвалась.

Параллельно я оставил Vite в devDependencies — Nuxt всё равно использует его под капотом, а мне нужно было аккуратно переехать, не полностью ломая привычный dev‑флоу.


Шаг 2. Новый app.vue и лоадер поверх SSR

Nuxt ожидает файл app.vue, поэтому я сделал тонкую обёртку вокруг контента и добавил туда лоадер:

vue Copy
<script setup lang="ts">
import { ref, onMounted } from 'vue';

const loaderVisible = ref(true);

onMounted(() => {
  setTimeout(() => {
    loaderVisible.value = false;
  }, 300);
});
</script>

<template>
  <div class="app-root">
    <NuxtPage />
    <div v-show="loaderVisible" id="app-loader" class="app-loader__wrapper">
      <div class="app-loader__spinner"></div>
    </div>
  </div>
</template>

А нужные стили для спиннера я инлайн‑подключил через app.head.style в nuxt.config.ts. Это даёт несколько бонусов:

  • SSR‑HTML появляется сразу, а лоадер мягко скрывается после монтирования Vue.
  • Нет скачка макета — лоадер живёт поверх страницы и просто исчезает по когда он больше не нужен.
  • Можно кастомизировать анимацию независимо от основного CSS‑пайплайна.

Шаг 3. Миграция главной страницы в Nuxt pages

Дальше нужно было превратить главную SPA‑страницу в полноценную Nuxt‑страницу. Я сделал два файла:

  1. pages/index.vue — редирект на локализованный URL на основе cookie.
  2. pages/[[locale]].vue — основная страница с секциями, данными и SSR.

Редирект выглядит максимально просто:

vue Copy
<script setup lang="ts">
const cookie = useCookie<string>('user-locale', { sameSite: 'lax', path: '/' });
const target = (cookie.value || 'ru').toLowerCase();

await navigateTo(`/${target}`, { redirectCode: 302 });
</script>

<template>
  <div />
</template>

А вот основная страница (фрагмент):

vue Copy
<script setup lang="ts">
import { onMounted, onUnmounted, defineAsyncComponent, computed } from 'vue';

import { PageSectionsEnum } from '@/enums/page-sections.enum';
import Header from '@/view/components/header/header.vue';

import { useMacrulezBadge } from '~/composables/useMacrulezBadge';
import { useScrollRouting } from '@/view/composables/use-scroll-routing';
import { useSectionsConfig } from '~/composables/useSectionsConfig';

const MetricsPanel = defineAsyncComponent(
  () => import('@/view/components/metrics/metrics-panel.vue'),
);

const route = useRoute();
const locale = computed(() => String(route.params.locale || 'ru'));

// HTML-атрибут lang на корневом <html>
useHead(() => ({
  htmlAttrs: { lang: locale.value },
}));

// SSR-данные через composables (useAsyncData + $fetch)
const { data: experienceRes } = await usePortfolioExperience(locale);
const { data: npmRes }         = await usePortfolioNpmPackages(locale);
const { data: artsRes }        = await usePortfolioArts();

const { init, destroy } = useScrollRouting();
const { sectionsConfig } = useSectionsConfig();

useMacrulezBadge();

onMounted(() => init());
onUnmounted(() => destroy());
</script>

<template>
  <div class="app">
    <Header />

    <section v-for="section in sectionsConfig" :id="section.id" :key="section.id">
      <component
        :is="section.component"
        v-bind="/* сюда подмешиваю SSR-данные по секции */"
      />
    </section>
  </div>

  <component :is="MetricsPanel" />
</template>

Здесь важны несколько моментов:

  • Перенёс существующий конфиг секций (useSectionsConfig) практически без изменений.
  • Подготовил SSR‑данные заранее (usePortfolioExperience, usePortfolioNpmPackages, usePortfolioArts) и уже потом пробрасываю их в нужные компоненты через v-bind.
  • Скролл‑роутинг (useScrollRouting) остался тем же, только теперь живёт внутри Nuxt‑страницы.
  • Локаль берётся из route.params.locale, и сразу же проставляется в <html lang="..."> через useHead.

Шаг 4. Использовать Nuxt server API вместо прямых вызовов

Раньше фронтенд ходил напрямую к macrulez-api. Теперь между ними появился тонкий слой в server/api/portfolio/*, который:

  • Прячет реальные URL’ы за runtimeConfig.macrulezApiBase.
  • Нормализует ответы (особенно это полезно для многоязычных полей).
  • Кэширует данные через cachedEventHandler.

Пример для опыта работы:

ts Copy
// server/api/portfolio/company.get.ts (фрагмент)
import { cachedEventHandler } from 'nitropack/runtime';
import { Agent } from 'undici';

const ipv4Agent = new Agent({ connect: { family: 4 } });

export default cachedEventHandler(
  async event => {
    const query = getQuery(event);
    const lang = typeof query.lang === 'string' ? query.lang : 'ru';

    const { macrulezApiBase } = useRuntimeConfig(event);

    const response = await $fetch(`${macrulezApiBase}/portfolio/company`, {
      query: { lang },
      timeout: 7000,
      retry: 1,
      dispatcher: ipv4Agent,
    });

    // ...нормализация payload и маппинг в аккуратный массив ExperienceItem...

    return { success: true, data: items };
  },
  {
    maxAge: 60,
    swr: true,
    name: 'portfolio-company',
    getKey: event => {
      const query = getQuery(event);
      const lang = typeof query.lang === 'string' ? query.lang : 'ru';
      return `portfolio-company:${lang}`;
    },
  },
);

А на стороне страницы всё сводится к простому composable:

ts Copy
// composables/usePortfolioExperience.ts (фрагмент)
export function usePortfolioExperience(lang: MaybeRef<string>) {
  const langRef = toRef(lang);

  return useAsyncData(
    () => `portfolio-company:${langRef.value}`,
    () =>
      $fetch<PortfolioResponse<ExperienceItem[]>>('/api/portfolio/company', {
        query: { lang: langRef.value || 'ru' },
      }),
    { watch: [langRef] },
  );
}

Что это даёт:

  • SSR‑данные приходят на сервере, кладутся в payload и потом просто гидрируются на клиенте.
  • Кэш на уровне Nitro — API дергается не на каждый запрос, а по мере устаревания.
  • Клиентский код максимально упрощён: вместо «работы с API» — обычный $fetch по относительному пути.

Шаг 5. Smoke‑страницы для проверки SSR

Перед тем как везти на SSR всю «тяжёлую» главную страницу, я сделал три простых smoke‑страницы:

  • pages/ssr-smoke.vue — опыт работы.
  • pages/ssr-smoke-arts.vue — арт‑галерея.
  • pages/ssr-smoke-npm.vue — npm‑пакеты.

Пример:

vue Copy
<script setup lang="ts">
const { data, pending, error } = await usePortfolioExperience('ru');
</script>

<template>
  <main style="padding: 24px; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
    <h1>Nuxt SSR smoke page</h1>
    <p>Страница должна рендериться на сервере и получать данные через Nuxt server API proxy.</p>

    <div v-if="pending">Loading…</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <ul v-else>
      <li v-for="c in data || []" :key="c.id">
        <strong>{{ c.company }}</strong> — {{ c.position }}
      </li>
    </ul>
  </main>
</template>

Зачем это нужно:

  • Быстро проверить SSR‑цепочку целиком: Nuxt → Nitro route → macrulez-api → HTML.
  • Удобно дебажить ошибки: если что-то не так с тайм-аутом, кэшем или IPv4/IPv6 — видеть это проще на маленькой странице, чем на всей главной.

Шаг 6. Метаданные, OG и конфиг окружения

Ещё один пласт миграции — привести в порядок метаданные и окружение.

В nuxt.config.ts я собрал всё, что раньше жилo в index.html+Vite:

ts Copy
const SITE_URL      = env('VITE_APP_URL')           || 'https://macrulez.ru';
const OG_IMAGE_PATH = env('VITE_APP_OG_IMAGE_PATH') || '/og-image.png';
const OG_IMAGE_URL  = joinUrl(SITE_URL, OG_IMAGE_PATH);

export default defineNuxtConfig({
  app: {
    head: {
      title: env('VITE_APP_TITLE') || '',
      meta: [
        { charset: 'utf-8' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },

        { name: 'description', content: env('VITE_APP_DESCRIPTION') || '' },
        { name: 'keywords',     content: env('VITE_APP_KEYWORDS')     || '' },

        // Open Graph
        { property: 'og:title',       content: env('VITE_APP_OG_TITLE')       || '' },
        { property: 'og:description', content: env('VITE_APP_OG_DESCRIPTION') || '' },
        { property: 'og:image',       content: OG_IMAGE_URL },
        { property: 'og:url',         content: SITE_URL },

        // Twitter и т.д.
      ],
      link: [
        { rel: 'icon', type: 'image/png',    href: '/favicon-96x96.png', sizes: '96x96' },
        { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
        { rel: 'shortcut icon',              href: '/favicon.ico' },
        { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
        { rel: 'manifest', href: '/site.webmanifest' },
      ],
    },
  },
});

Пара трюков:

  • Я написал маленькую утилиту loadDotEnvFile, чтобы подтягивать .env, если он есть рядом с nuxt.config.ts. Это делает dev‑окружение более предсказуемым.
  • Переменные продолжают начинаться с VITE_APP_* — чтобы не переписывать существующую конфигурацию, но теперь они живут в контексте Nuxt.

Шаг 7. Деплой Nuxt‑приложения через GitHub Actions и PM2

Отдельный кусок работы — привести деплой к виду, в котором Nuxt‑SSR живёт как отдельный Node‑процесс под PM2.

Текущий workflow (.github/workflows/deploy.yml) делает примерно следующее:

  • Жёстко синхронизирует код с origin/master через git reset --hard и git clean -fd.
  • Проверяет наличие package-lock.json, чтобы сборки были воспроизводимыми.
  • Аккуратно подгружает .env (с игнором ошибок, чтобы деплой не падал от мелочей).
  • Полностью чистит артефакты: rm -rf node_modules .output .nuxt.
  • Ставит зависимости через npm ci.
  • Собирает проект через npm run build (под капотом nuxi build).
  • Убивает старые процессы PM2 и освобождает порт 3001, после чего запускает:
bash Copy
PORT=3001 pm2 start .output/server/index.mjs \
  --name macrulez.ru \
  --update-env

Результат:

  • SSR‑приложение крутится как отдельный Node‑процесс, который легко перезапустить, посмотреть логи и т.д.
  • В проде нет лишних файлов — перед каждым деплоем всё, что может мешать, удаляется.
  • GitHub Actions даёт прозрачный лог деплоя с шагами, которые можно отладить по отдельности.

Немного о плавной миграции и композиционных функциях

Интересный побочный эффект переезда — оказалось, что большая часть композиционных функций и компонентной архитектуры вообще не зависит от того, «Nuxt это или Vite».

Например, конфиг секций (useSectionsConfig) выглядит по сути фреймворк‑агностично:

ts Copy
const allSections: Partial<Record<PageSectionsEnum, Component>> = {
  [PageSectionsEnum.SPLASH]:          Splash,
  [PageSectionsEnum.ABOUT]:           About,
  [PageSectionsEnum.EXPERIENCE]:      ExperienceTimeline,
  [PageSectionsEnum.TRAVELSHOP]:      TravelshopProject,
  [PageSectionsEnum.FEATURES]:        Examples,
  [PageSectionsEnum.STUFF]:          Stuff,
  [PageSectionsEnum.ARTS]:           Arts,
  [PageSectionsEnum.REMOTE_WORKPLACE]: RemoteWorkplace,
  [PageSectionsEnum.CONTACTS]:       Contacts,
};

const sectionOrder = ref<PageSectionsEnum[]>([
  PageSectionsEnum.SPLASH,
  PageSectionsEnum.ABOUT,
  PageSectionsEnum.EXPERIENCE,
  // ...
  PageSectionsEnum.CONTACTS,
  PageSectionsEnum.BLOG,
]);

export const sectionsConfig = computed(() =>
  sectionOrder.value
    .filter(id => Boolean(allSections[id]))
    .map(id => ({ id, component: allSections[id] as Component })),
);

Nuxt здесь просто даёт более удобную «обвязку» (страницы, server API, конфиг окружения), но не ломает архитектуру, которая уже была.


Итог

Если коротко, после миграции получилось следующее:

  • Сайт больше не SPA в чистом виде — первая отрисовка и данные приходят с сервера.
  • Фронт ходит не напрямую к macrulez-api, а через Nuxt server API с кэшем и нормализацией.
  • Структура проекта осталась узнаваемой: компоненты, стили и композиционные функции живут там же, но в более комфортной среде.
  • Деплой стал честным Node‑SSR с PM2 и жёсткой синхронизацией с git.

При этом я избежал полного «переписать всё» — Nuxt встал поверх существующего кода, аккуратно вытянув наружу то, что раньше было сложнее реализовать: SSR, server‑side data fetching и предсказуемый деплой.

Читать далее

01.03.2026

Toolz - в помощь вебмастеру

Краткий обзор возможностей трёх инструментов на toolz.macrulez.ru — сжатие растровых картинок с разными кодеками и настройками, оптимизация SVG и конструктор CSS-градиентов. Что можно настроить, как смотреть результат и как обрабатывать файлы пачками.

Метки
toolzизображенияоптимизацияSVGградиентыCSS
03.03.2026

Toolz: редактируем очередь изображений в процессе работы

Теперь в Image Compressor и Tiny SVG можно не только сжимать изображения пачками, но и редактировать саму очередь на лету — добавлять новые файлы и удалять лишние без перезапуска всего процесса. В статье разбираем, как эта мелочь по интерфейсу заметно упрощает подготовку ассетов для веба.

Метки
toolzочередьизображения
03.03.2026

Toolz: переименовываем файлы при пакетной обработке

Больше никакого ручного переименования десятков картинок после сжатия — разбираемся, как новые шаблоны в Image Compressor автоматически приводят имена файлов к нужному виду на лету.

Метки
Image Compressorпакетная обработкапереименование файлов