В мире фронтенд-разработки накопилось много разных технологий. Вместе с каждой из этих технологий обычно следует мета-фреймворк — инструмент и большая экосистема библиотек, которые создают юзеры опенсорс-пространства. Для React это Next.js, для Svelte — SvelteKit, а для Vue — Nuxt. Сегодняшняя статья будет именно про него.
Nuxt дополняет Vue как мета-фреймворк — он предоставляет полную поддержку SSR из коробки, новомодный файловый роутер и возможность построить бэкенд прямо изнутри приложения с помощью серверных роутов. По ходу этой статьи мы задействуем все возможности Nuxt и выстроим архитектуру проекта согласно best practices.
Структура статьи
В этой статье мы напишем среднего размера Fullstack-приложение публичных заметок — Deep Thoughts (Глубокие Мысли). В приложении можно будет оставлять записи на главной странице. Для интерфейса мы будем использовать UI-кит shadcn-vue, а также рассмотрим решение проблем его интеграции с Nuxt. Напоследок, научимся сохранять все данные в базу данных MySQL и работать с ней через Prisma. Во время разработки воспользуемся Docker Compose для поднятия базы данных, а в конце статьи создадим базу в инфраструктуре Timeweb Cloud и задеплоим проект через Dockerfile с помощью Timeweb Cloud App Platform.
Подготовка проекта
Для написания проекта на Nuxt нужны установленные node и npm. Мы будем использовать версию 20. Их можно установить с официального сайта Node.js.
Тут у вас есть две опции: использовать инсталлятор, который установит только одну определенную версию node, или использовать пакетный менеджер (nvm для Linux, fvm для Windows), с помощью которого можно легко переключаться между версиями (например, nvm use 18).

После установки node перезапустите терминал и попробуйте команду npm -v. На выводе должна быть версия:

Теперь давайте инициализируем новый проект на Nuxt. Для этого используем следующую команду:

В качестве пакетного менеджера выбираем npm.
Установка и кастомизация shadcn
Теперь давайте заведем на проект наш UI-кит. Актуальный гайд по установке можно посмотреть в официальной документации.
Для начала давайте обсудим, что такое shadcn. Вместо того, чтобы завернуть все свои компоненты в библиотеку-npm-пакет, shadcn использовал дополнительный CLI (Command-Line Interface), который позволяет скачать полноценный компонент прямо в свой проект без каких-либо зависимостей (помимо общих библиотек экосистемы, вроде react-hook-form).
Многим полюбился этот подход, и библиотека приобрела огромную популярность. Позже ее портировали для Svelte и Vue практически в полном виде. Порт для Vue использует под капотом библиотеку radix-vue, для форм же используется vee-validate.
Для инициализации библиотеки введем несколько команд.
Установим typescript для решения общих проблем с Nuxt и Typescript:
Установим модуль Tailwind для Nuxt:
Если вы не знакомы с Tailwind, рекомендуем ознакомиться с этой технологией на официальном сайте.
Установим модуль shadcn:
Запустим инициализацию CLI shadcn.
В процессе выбираем все дефолтные значения, а в качестве фреймворка — Nuxt.

В итоге мы должны получить такую структуру папок:

Также давайте создадим еще один файл и назовем его main.css:
Он импортирует созданный shadcn-файл tailwind.css, в котором добавляются дефолтные импорты Tailwind и переменные темы, а также добавит стили, чтобы растянуть <body> на весь экран.
Чтобы Nuxt учитывал наш главный CSS-файл, мы должны указать его в nuxt.config.ts:
Теперь давайте добавим в проект первый компонент — обычную кнопку, чтобы убедиться, что все работает как нужно:
Далее создадим папку pages, а в ней файл index.vue с дефолтным шаблоном для Vue:
Также нам нужно будет изменить файл app.vue. Это корневой файл приложения. Сейчас в нем следующая запись:
Нам же нужно будет убрать приветственный экран Nuxt и привести все к такому виду:
Это позволит нам использовать файловый роутер.
Давайте добавим на страницу index.vue компонент кнопки, который автоимпортируется с помощью Nuxt:
Почитать об автоимпортах можно в документации. Если вы хотите как-то выделить компоненты из юайкита, вы можете добавить им префикс в nuxt.config.ts:
Запускаем сервер командой:
И на выходе получаем нашу долгожданную кнопку:

Напоследок, давайте добавим все необходимые для нашего проекта компоненты UI-kit’а. Сделаем мы это заранее и сразу же устраним проблему, которая обязательно возникнет при этом. Введем следующую команду:
CLI добавит на наш проект компоненты карточки, формы, полей ввода и label, а также установит библиотеку vee-validate.
Теперь давайте попробуем использовать компонент формы, согласно документации библиотеки:
Инпут появится на экране, но в консоли будут следующие ошибки:

Все потому, что компонент FormItem использует хук useId из radix-vue, который создает мисмэтч (mismatch — несовпадение) HTML при SSR в Nuxt, потому что он не адаптирован для серверного рендеринга. Для исправления этой ошибки давайте добавим специальную обертку из radix-vue в app.vue, как предлагают на Гитхабе. Она заменит имплементацию radix-vue на автоимпорт Nuxt:
Ошибка ушла:

Теперь все готово для дальнейшей работы с проектом. Перейдем к базе данных!
Добавление базы данных
Теперь давайте добавим в наш проект базу данных и Prisma. Мы будем использовать MySQL, который развернем локально с помощью Docker Compose.
В нашем случае мы будем использовать Docker для разработки — подтянем образ MySQL, чтобы не устанавливать MySQL на сам компьютер. Позже мы напишем Dockerfile, с помощью которого наш проект будет разворачиваться через Timeweb Cloud.
Чтобы использовать Docker Compose, нужно установить сам Docker. Инструкции вы можете найти в документации — выберите вашу операционную систему и следуйте руководству. Также у нас есть своя статья по установке Docker на Ubuntu 22.04.
После установки Docker напишем файл compose.yml, который будет подтягивать образ MySQL:
Далее добавим переменные окружения, чтобы управлять паролем root-пользователя MySQL и выбирать базу, с которой будем работать. Для этого добавим файл .env со следующими переменными:
И используем их в compose.yml:
Далее нужно привязать данные, которые будет использовать контейнер, к данным нашего компьютера. Это необходимо для того, чтобы после перезагрузки контейнер сохранял все, что было в него добавлено. Для этого добавим следующие записи:
Блок volumes под db означает: сохраняй в папку db_data на моем компьютере все, что лежит в папке /var/lib/mysql в контейнере. Запись ниже дает докеру знать, что у нас есть volume с таким именем. Он будет использовать дефолтный конфиг сохранения для db_data, чего нам достаточно.
Чтобы наше приложение смогло взаимодействовать с базой, нужно пробросить порты из контейнера наружу. Делается это с помощью записи:
Она означает, что порт слева (на нашем компьютере) будет смотреть на порт справа (в контейнере), и наше приложение сможет воспользоваться базой через порт 3306, который соответствует порту 3306 в контейнере.
Напоследок добавим проверку статуса запуска контейнера — healthcheck. Это нужно, чтобы знать, когда база уже готова принимать запросы и можно запустить приложение, а когда нет. По сути, healthcheck будет состоять в том, что в контейнере будет выполняться команда mysqladmin ping в определенном интервале, что даст Докеру знать, «здоров, работает» контейнер или нет. Финальная запись compose.yml:
Попробуем запустить Docker:
Если видите такую запись:

— значит все хорошо и контейнер успешно запущен!
App Platform
и тестирования проектов из GitHub, GitLab, Bitbucket
или любого другого git-репозитория.
Инициализация Prisma
Prisma — одна из самых популярных ORM (Object-Relational Mapper) для Node.js. У этой библиотеки огромное комьюнити и годы разработки и исправленных багов позади. Prisma имеет крайне дружелюбное API, с помощью которого можно писать простые приложения с базами данных даже без особого знания SQL.
Давайте добавим Prisma на проект. Для этого установим две библиотеки:
Первая — это CLI Prisma, с помощью которого мы инициализируем проект, будем добавлять миграции, чтобы позже иметь возможность полностью восстановить структуры базы данных по щелчку пальцев, а также применять изменения структуры напрямую к базе. Вторая — это клиент для Prisma, с помощью которого мы будем делать сами запросы в коде.
Далее напишем:
Это создаст папку prisma в корне проекта и файл schema.prisma внутри. Немного изменим структуру файла, чтобы она была такой:
После этого добавим переменную DATABASE_URL в наш .env-файл:
Здесь мы используем интерполяцию переменных, которые добавили ранее, чтобы при их изменении мы могли не беспокоиться о синхронизации между ними.
Далее добавим в схему Prisma нашу модель мысли (Thought):
К модели Prisma создаст таблицу в базе данных с указанными полями.
idбудет числовым и с каждой записью будет увеличиваться на 1 с помощью@default(autoincrement).- К полю
textмы добавим@db.VarChar(256), чтобы оно могло хранить 256 символов, а не 191, как оно хранило бы просто с типом String. createdAtбудет выставляться автоматически при создании записи.
Наконец, убедимся, что контейнер запущен в одном из терминалов, и, перейдя в другой терминал, выполним команду:

Если ваш вывод похож на этот, то мы успешно обновили базу данных в Docker.
Для улучшения DX (Developer Experience), теперь можно написать небольшой скрипт для запуска проекта в одну команду. Он будет поднимать контейнер с базой данных, накатывать актуальные миграции и запускать проект:

Все работает!
Наконец, давайте добавим инициализацию клиента Prisma в папку server/db:
Метод createPrisma возвращает инстанс класса PrismaClient, который сгенерировал CLI призмы. Далее идет объявление глобальной переменной db и ее инициализация и переинициализация в случае, если окружение не является продовым. Это сделано для того, чтобы при Hot-Reload сервера Nuxt не создавал новый инстанс PrismaClient, а переопределял текущий, и всегда использовал только один-единственный.
Интерфейс формы отправки
Далее напишем форму создания Глубокой мысли.
Для валидации форм мы будем использовать zod, популярную библиотеку для валидации данных. Также zod полностью интегрирован с TypeScript и vee-validate. Когда мы устанавливали компонент form из shadcn, CLI автоматически установил библиотеку zod и адаптер для vee-validate — @vee-validate/zod — на наш проект.
Так как мы пишем Fullstack-приложение, чуть позже мы добавим логику валидации данных на сервере, поэтому давайте создадим отдельную папку schemas в корне проекта для хранения валидационных схем zod. Добавим туда файл thought.ts, в котором объявим простую схему zod и экспортируем ее и TypeScript-интерфейс, который генерирует zod через utility-тип z.Infer<T>:
Теперь напишем логику формы в папке components/thought/form:
Структура компонента будет такой: мы используем компонент Card в качестве обертки, сделаем два поля, как в схеме: имя пользователя и текст мысли. Текст мыслей мы реализуем с помощью Textarea. При этом мы добавим счетчик символов и ограничим количество символов константой, которую объявили в файле со схемами. Еще мы хотим, чтобы при нажатии Shift+Enter сообщение отправлялось без нажатия на кнопку — для этого мы привяжем слушатель @keydown.enter.prevent с проверкой на event.shiftKey. Внизу карточки мы сделаем вывод ошибки — красного текста, если она будет прокинута снаружи. Все это выглядит примерно так:
Давайте посмотрим на наш компонент:

Под конец, давайте добавим возможность очищать форму по нашему желанию в компоненте родителя. Так мы сможем очищать форму не сразу после отправки, а только если отправка была успешной, чтобы пользователь смог повторно использовать то, что уже написал в поле ввода.
Такой функционал можно реализовать с помощью метода defineExpose. Для этого создадим в папке components/thought/form файл types.ts:
И в самом компоненте добавим импорт:
Теперь нам нужно добавить ref в компоненте-родителе:
И стороннюю кнопку, которая будет сбрасывать состояние формы:
Введем что-то:

И нажмем на кнопку:

Все работает!
Серверная логика
Теперь давайте напишем логику на сервере, которая будет принимать запросы с формы и сохранять полученные данные в базу данных, а также добавим возможность получать все сохраненные мысли.
Подробнее узнать о том, как работает бэкенд-составляющая Nuxt, можно в официальной доке. Вкратце, при инициализации проекта также создается папка server. В ней мы можем создать папку routes или api, в которых можем создавать папки и файлы для REST API.
Nuxt основан на наборе инструментов (тулките) Nitro. Nitro — обертка для HTTP-фреймворков вроде h3 (также используется в Nuxt), которая добавляет много удобств, в основе которых — легкий деплой на огромное количество платформ. Обе технологии являются частью экосистемы unjs. Например, мы также будем использовать утилиту $fetch из библиотеки unjs/ofetch, которая заменяет fetch браузера для большей совместимости. В экосистеме много других модулей, посмотреть их можно на Гитхабе.
Для того, чтобы создать API с Nitro, создадим файл в папке server/routes/thought:
И, запустив проект, перейдем на URL /thought:

Мы написали первый API-роут!
Файловая система в папке server/routes работает так же, как файловый роутер в pages: мы можем создавать API-ручки с помощью названия папок и файлов. Суффикс get означает, что по роуту /thought будет работать только GET-запрос. Например, если мы изменим суффикс на post, то в браузере увидим страницу 404:

Метод defineEventHandler принимает в себя метод-колбек для роута, в котором доступна переменная event. Ее можно использовать по-разному — подробнее о нем можно прочитать в документации. В нашем же случае мы будем парсить из нее body запроса с помощью readBody.
Давайте добавим в файл index.post.ts логику создания мысли:
Последовательность такая: мы парсим body из входящего event с помощью метода Nuxt readBody, потом с помощью zod-схемы, которую мы объявили ранее, парсим body методом safeParseAsync. Затем мы проверяем, успешно ли провалидировалось body, если нет, то бросаем ошибку 422 — UNPROCESSABLE_CONTENT с сообщением первой ошибки. Далее, мы создаем запись мысли в базе данных и возвращаем созданную запись.
Для более простого взаимодействия со статус-кодами HTTP можно использовать библиотеку http-status-codes:
Используем ее так (не забываем убрать .data с bodyParsed!):
Под конец файл выглядит так:
Взаимодействие клиента с сервером
Теперь давайте свяжем логику сервера с нашей формой. В app.vue внесем следующие изменения:
Мы добавили состояние error для хранения ошибки и метод для отправки запроса на сервер с помощью $fetch. После успешной отправки формы мы будем сбрасывать состояние формы. При ошибке — чекать err на принадлежность к инстансу Error, чтобы не возникло ошибки с типами и не писать any, и переопределяем состояние error.
Попробуйте добавить мысль. Если форма сбросилась — значит, мысль добавлена успешно.
Отображение мыслей
Для отображения списка мыслей последуем тому же сценарию разработки. Для начала создадим компоненты ThoughtCard и ThoughtList в папке components/thought:
Тут мы отображаем имя пользователя, текст сообщения и отформатированную локально дату создания мысли в Card. В качестве типа используем генерируемый тип Thought из @prisma/client.
В этом компоненте мы отрисовываем <section> с текстом, что мыслей нет, или если они есть, то выводим ul-список карточек.
В компоненте страницы добавим вызов хука useFetch:
useFetch — один из способов получить данные с API в Nuxt. Он специально адаптирован для работы с внутренними серверными роутами. Для использования стороннего API можно было бы использовать useAsyncData. Хотя useFetch сам подтягивает возвращаемое значение ручки /thought, мы указываем Generic: <Thought[]> и default-значение вызова, чтобы не было ошибки с типами в компоненте.
В <template> добавляем следующее:
— чтобы отображать ошибку, если она есть, или список, в который мы прокидываем переменную thoughts.
Нам осталось только добавить серверный роут /thought для GET-запроса в файле server/routes/index.get.ts:
Посмотрим, что получилось:

Для хорошего UX (User Experience) добавим обновление списка после успешной отправки формы:
Мы деструктуризируем метод refresh из useFetch и используем его в handleSubmit. Попробуйте — теперь новая мысль будет сразу же появляться на экране.
Поздравляю! Вместе мы успешно написали небольшое CRUD-приложение на Nuxt с нашей собственной базой данных. Мы подходим к концу — осталось заказать базу и сервис App Platform для деплоя приложений и загрузить туда код вместе с Dockerfile, и наш проект увидит свет!
Деплой
Для деплоя мы сделаем следующее:
- Напишем Dockerfile
- Создадим облачную базу данных
- Создадим Github-репозиторий и запушим наш проект туда
- Задеплоим наше приложение через сервис Timeweb Cloud App Platform
Dockerfile
В Dockerfile мы распишем все этапы сборки проекта в контейнер, чтобы сервис App Platform понял, чего мы от него хотим. Перед созданием Dockerfile создадим .dockerignore. Этот файл внесет в черный список файлы, которые мы не хотели бы переносить из проекта в контейнер, например, папки, где хранится информация о git-репозитории или уже готового .nuxt-билда:
Теперь создадим Dockerfile в корне проекта. Мы разобьем деплой на части, чтобы был понятен процесс сборки проекта.
Стадия первая: выбираем нужный образ (node-slim), добавляем package.json и package-lock.json и устанавливаем зависимости. Также нам нужно прокинуть ARG DATABASE_URL для приложения:
Копируем остальные файлы, запускаем миграции и билдим проект:
Создаем финальный образ, в который скопируем готовый билд, запустим его на порту 3000 и откроем порт для того, чтобы сервис App Platform знал, на каком порту нужно прослушивать приложение:
Финальный Dockerfile будет выглядеть так:
База данных
Теперь давайте перейдем к заказу базы данных в панели Timeweb Cloud (в меню: Базы данных → Создать). Выбирайте подходящие характеристики базы данных — можно оставить все стандартным, или перенести сервер в Нидерланды и взять конфигурацию помощнее.
После нажатия кнопки заказать нужно подождать пару минут для запуска базы.
Пока ждем, создадим нового пользователя (вкладка «Пользователи» → «Добавить» и назовем его user. Так как Timeweb Cloud по умолчанию генерирует сложные пароли со специальными символами, нам нужно URL-закодировать пароль. Скопируйте его и отнесите на сервис urlencoder. Далее давайте соберем публичную строку подключения к базе:
Github
Теперь давайте создадим репозиторий на Гитхабе и отправим туда код. Для этого заходим в свой аккаунт github.com и нажимаем кнопку с плюсом справа вверху. Создаем приватный репозиторий и копируем на него ссылку в браузере. В проекте добавляем ссылку на удаленный репозиторий командой:
Перед тем, как создать коммит, давайте напишем npx prisma migrate dev с поднятой в Докере базой данных. Prisma создаст файл с миграциями. Миграции нужны, чтобы с легкостью воспроизводить схему базы данных в любых окружениях, полностью повторяя историю ее изменений за все время разработки. Далее мы используем их в Dockerfile для применения схемы к продакшен-базе. Миграции нужно создавать нечасто — например, когда вы уже собрали готовый прототип приложения и хотите отправить изменения в базу, где уже есть записи. Миграции применятся к ней так, чтобы точно ничего не сломать.
Далее создаем коммит:
И отправляем его в репозиторий командой:
Сервис App Platform
В панели Timeweb Cloud переходим к созданию нового приложения (в меню: App Platform → Создать).
1. В переключателе вкладок выбираем Dockerfile.
2. В пункте «Репозиторий» выбираем Github.
3. Подтверждаем привязку аккаунта:

4. После привязки аккаунта выбираем наш репозиторий:

5. И тот же регион, что выбирали для базы данных. Это снизит задержку.
6. Далее выбираем сервер с как минимум 4 ГБ ОЗУ, чтобы избежать проблем с нехваткой памяти.
7. Под конец добавим единственную переменную окружения — строку, которую составили ранее под названием DATABASE_URL:

8. После этого запускаем деплой, и смотрим за билдом!
Сделать это можно во вкладке «Деплой», где нажимаем «Включить расширенные логи» (нужно будет немного подождать):

Когда деплой успешно завершится, перейдем по домену, который указан в дашборде нашего приложения:

Готово!
Выгодные тарифы на VDS/VPS
477 ₽/мес
657 ₽/мес
Заключение
Мы создали небольшое приложение на Nuxt, которое умеет сохранять данные в базу данных и выводить их пользователям, а затем развернули его в Timeweb Cloud App Platform. Приложение готово к тому, чтобы дорабатывать его, создавать новые фичи, добавляя их в схему Prisma и пользуясь серверными роутами Nuxt. Благодаря полной типизации между клиентом и сервером, легко изменять уже готовый функционал, если мы, например, хотим поменять названия полей в базе. Исходный код приложения доступен на Гитхабе.
Дополнительно в проект также можно добавить SEO-информацию и фавикон — их можно добавить в nuxt.config.ts по гайду Nuxt. Можно также обзавестись авторизацией, чтобы отличать пользователей друг от друга не только на основе одного поля ввода, а также ограничить доступ к сообщениям людям без аккаунта. С помощью генератора можно создать свою уникальную тему для shadcn и добавить на проект Dark Mode.
