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
// 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
<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‑страницу. Я сделал два файла:
pages/index.vue— редирект на локализованный URL на основе cookie.pages/[[locale]].vue— основная страница с секциями, данными и SSR.
Редирект выглядит максимально просто:
vue
<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
<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
// 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
// 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
<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
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
PORT=3001 pm2 start .output/server/index.mjs \
--name macrulez.ru \
--update-env
Результат:
- SSR‑приложение крутится как отдельный Node‑процесс, который легко перезапустить, посмотреть логи и т.д.
- В проде нет лишних файлов — перед каждым деплоем всё, что может мешать, удаляется.
- GitHub Actions даёт прозрачный лог деплоя с шагами, которые можно отладить по отдельности.
Немного о плавной миграции и композиционных функциях
Интересный побочный эффект переезда — оказалось, что большая часть композиционных функций и компонентной архитектуры вообще не зависит от того, «Nuxt это или Vite».
Например, конфиг секций (useSectionsConfig) выглядит по сути фреймворк‑агностично:
ts
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 и предсказуемый деплой.