Главная / Статьи

Добавление PostgreSQL, Drizzle и Auth в монорепозиторий Nest.js с tRPC и Turborepo

Расширьте типобезопасный монорепозиторий TypeScript с помощью Docker Postgres, схем drizzle-orm, модулей users и posts в Nest.js, маршрутов аутентификации JWT и формы входа в React.

Издатель: REACTAPP.TOP13 мин чтения3002 слов

Источники: My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript, part 2

Это второй этап настройки монорепозитория для полностью типобезопасного TypeScript с поддержкой концепции end-to-end. На первом этапе использовался бэкенд Nest.js с интерфейсами tRPC, а также два фронтенда: одностраничное приложение на React Router и Vite, и сайт на Astro, все из которых управлялись через Turborepo. Здесь добавляется база данных PostgreSQL с помощью drizzle-orm, включая механизмы базовой аутентификации и приватные процедуры tRPC, которые позволяют читать и создавать данные только зарегистрированным пользователям. Если раньше в монорепозитории был API и два фронтенда, то теперь за API также находится база данных.

Полная копия кода хранится на ветке database-and-auth репозитория monorepo-setup.

Запуск PostgreSQL с помощью Docker Compose

База данных запускается на основе файла compose.yaml, расположенного в корне монорепозитория:

name: monorepo-setup

services:
  db:
    image: postgres:17-alpine
    container_name: monorepo-setup-database-container
    ports:
      - 127.0.0.1:5432:5432
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5
    volumes:
      - monorepo-setup:/var/lib/postgresql/data
    env_file:
      - apps/api/.env.database

volumes:
  monorepo-setup:
    name: monorepo-setup

При запуске, если изображение PostgreSQL 17 Alpine не находится в кэше, программа загружает его, создаёт контейнер с именем monorepo-setup-database-container, открывает порт 5432 на локальном хосте и хранит данные в объёме Docker с указанным именем. Для корректной работы контейнеру требуется файл .env.database, например:

# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres

Поднимите контейнер с помощью:

docker compose up -d --remove-orphans

Затем подтвердите, что он жив:

docker ps

В списке должен отображаться здоровый контейнер. Чтобы проверить, была ли создана пустая база данных, откройте сессию psql внутри контейнера:

docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
# monorepo-setup-database-container is the name of the container, as specified in the compose.yaml file
# -U postgres (POSTGRES_USER specified in the .env.database file)
# same for -d monorepo-setup (POSTGRES_DB)

Команда \dt выводит список таблиц, и на данный момент их нет, что является ожидаемым состоянием.

Установка и настройка drizzle-orm

Все дальнейшие действия выполняются в приложении api, поэтому перейдите в его папку:

# From the root of the monorepo
cd apps/api

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

npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg

В файле .env API необходима переменная DATABASE_URL следующего формата:

DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>

Укажите имя пользователя, пароль, хост и порт, заданные при настройке, а также имя базы данных из POSTGRES_DB. С использованием приведённых выше значений результат будет следующим:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup

Nest.js требует, чтобы drizzle был упакован в модуль, поэтому сгенерируйте его с помощью CLI:

nest g res drizzle

После удаления сгенерированного шаблонного кода остаётся лишь несколько файлов. В файле с константами хранится токен вставки:

// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";

Провайдер создаёт пул соединений и экземпляр drizzle:

// apps/api/drizzle/providers/drizzle.provider.ts

import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

import { EnvService } from "@/env/env.service";

import { DRIZZLE_ASYNC_PROVIDER } from "../constants";

export const drizzleProvider = {
  provide: DRIZZLE_ASYNC_PROVIDER,
  inject: [EnvService],
  useFactory: async (envService: EnvService) => {
    const connectionString = envService.get("DATABASE_URL");
    const pool = new Pool({
      connectionString,
    });

    return drizzle({ client: pool });
  },
};

Далее идет таблица пользователей:

// apps/api/src/drizzle/schema/users.ts

import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";

export const users = pgTable("users", {
  id: varchar("id", { length: 26 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => ulid().toLowerCase()),

  username: text("username").notNull().unique(),
  email: text("email").notNull().unique(),
  hashedPassword: text("hashed_password").notNull(),

  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});

А далее следует таблица постов:

// apps/api/src/drizzle/schema/posts.ts

import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";

import { users } from "./users";

export const posts = pgTable("posts", {
  id: varchar("id", { length: 26 })
    .primaryKey()
    .notNull()
    .$defaultFn(() => ulid().toLowerCase()),
  authorId: varchar("author_id", { length: 26 })
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),

  title: text("title").notNull(),
  slug: text("slug").notNull(),
  content: text("content").notNull(),

  publishedAt: timestamp("published_at"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at")
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull(),
});

Файл корневой схемы повторно экспортирует обе таблицы:

// apps/api/src/drizzle/schema/index.ts

export * from "./posts";
export * from "./users";

Идентификаторы являются ULIDs, поэтому соответствующий пакет Node необходимо установить в api:

npm i ulid

Соотношение между таблицами — один к многим. Один пользователь, автор, может написать множество постов, в то время как каждый пост принадлежит ровно одному пользователю.

В первой части был представлен модуль Env для типобезопасных переменных окружения, поэтому новую переменную DATABASE_URL необходимо добавить в зарегистрированную схему:

// apps/api/src/env.ts

import { z } from "zod";

export const envSchema = z.object({
  NODE_ENV: z.enum(["production", "development"]),

  JWT_SECRET: z.string().trim().min(1),

  DASHBOARD_URL: z.url(),
  WEB_APP_URL: z.url(),

  DATABASE_URL: z.string().trim().min(1),
});

export type Env = z.infer<typeof envSchema>;

Последним элементом конфигурации является файл drizzle.config.ts в корне API:

// apps/api/drizzle.config.ts

import { defineConfig } from "drizzle-kit";

import "dotenv/config";

export default defineConfig({
  out: "./src/drizzle/migrations",
  schema: "./src/drizzle/schema/index.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Он указывает drizzle записывать миграции в папку migrations внутри каталога модуля drizzle и читать корневую схему. Запуск миграций осуществляется с помощью двух скриптов в файле package.json API:

// apps/api/package.json

{
  "scripts": {
    ...
    "generate": "npx drizzle-kit generate",
    "migrate": "npx drizzle-kit migrate",
    ...
  }
}

Сначала сгенерируйте миграцию, которая создаст файл SQL в папке migrations:

npm run generate

Затем примените его к базе данных:

npm run generate

Теперь должны существовать таблицы как пользователей, так и постов. Проверьте их с помощью той же сессии psql, что и раньше:

docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup

за которым следует таблица с перечнем команд:

\dt

Drizzle Studio — это графическая альтернатива, которая реализуется с помощью ещё одного скрипта в package.json:

// apps/api/package.json

{
  "scripts": {
    ...
   "studio": "npx drizzle-kit studio"
    ...
  }
}

Начните с:

npm run studio

Теперь база данных подключена к API с помощью Drizzle-ORM. Для справки файл drizzle.module.ts должен выглядеть следующим образом:

import { EnvModule } from "@/env/env.module";
import { Module } from "@nestjs/common";

import { DRIZZLE_ASYNC_PROVIDER } from "./constants";
import { drizzleProvider } from "./providers/drizzle.provider";

@Module({
  imports: [EnvModule],
  providers: [drizzleProvider],
  exports: [DRIZZLE_ASYNC_PROVIDER],
})
export class DrizzleModule {}

А файл env.module.ts:

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

import { EnvService } from "./env.service";

@Module({
  imports: [ConfigModule],
  providers: [EnvService],
  exports: [EnvService],
})
export class EnvModule {}

Модули пользователей и постов

Для обоих ресурсов создаются модуль и сервис с базовыми методами CRUD. Для пользователей ресурс генерируется из папки api:

nest g res users

После удаления шаблонного кода логика находится в сервисе:

// apps/api/src/users/users.service.ts

import * as bcrypt from "bcrypt";
import { eq, or } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";

import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { users } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";

import { CreateUserDtoType, FindByEmailDtoType } from "./users.dto";

@Injectable()
export class UsersService {
  private readonly saltRounds = 10;

  constructor(
    @Inject(DRIZZLE_ASYNC_PROVIDER)
    private readonly db: NodePgDatabase,
  ) {}

  async create(createUserDto: CreateUserDtoType) {
    const { email, password, username } = createUserDto;

    const [existingUserWithSameEmailOrUsername] = await this.db
      .select({ email: users.email, username: users.username })
      .from(users)
      .where(or(eq(users.email, email), eq(users.username, username)))
      .limit(1);

    if (existingUserWithSameEmailOrUsername) {
      if (existingUserWithSameEmailOrUsername.email === email) {
        throw new TRPCError({
          code: "CONFLICT",
          message: "This email is already used",
        });
      } else {
        throw new TRPCError({
          code: "CONFLICT",
          message: "This username already exists",
        });
      }
    }

    const hashedPassword = await bcrypt.hash(password, this.saltRounds);

    const [createdUser] = await this.db
      .insert(users)
      .values({
        username,
        email,
        hashedPassword,
      })
      .returning({
        id: users.id,
        email: users.email,
        username: users.username,
      });

    return createdUser;
  }

  async findByEmail(findByEmailDto: FindByEmailDtoType) {
    const { email } = findByEmailDto;

    const [user] = await this.db
      .select({
        id: users.id,
        email: users.email,
        username: users.username,
        hashedPassword: users.hashedPassword,
      })
      .from(users)
      .where(eq(users.email, email))
      .limit(1);

    if (!user) {
      return null;
    }

    return user;
  }
}

Файл DTO определяет схемы zod и типы для входных данных каждого метода:

// apps/api/src/users/users.dto.ts

import z from "zod";

export const CreateUserDto = z.object({
  email: z.email(),
  username: z.string().min(1).max(25),
  password: z.string().min(1).max(72),
});

export const FindByEmailDto = z.object({
  email: z.email(),
});

export type CreateUserDtoType = z.infer<typeof CreateUserDto>;
export type FindByEmailDtoType = z.infer<typeof FindByEmailDto>;

Для хеширования паролей требуется bcrypt:

# apps/api

npm i bcrypt
npm i -D @types/bcrypt

Модуль пользователей должен импортировать модуль drizzle:

// apps/api/src/users/users.module.ts

import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";

import { UsersService } from "./users.service";

@Module({
  imports: [DrizzleModule],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Посты следуют тому же шаблону, начинаясь с информации о генерации:

nest g res posts

После очистки сервис публикаций выглядит так:

import { and, eq } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";

import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { posts } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";

import {
  CreatePostDtoType,
  FindAllPostsDtoType,
  FindOnePostDtoType,
} from "./posts.dto";

@Injectable()
export class PostsService {
  constructor(
    @Inject(DRIZZLE_ASYNC_PROVIDER)
    private readonly db: NodePgDatabase,
  ) {}
  async create(createPostDto: CreatePostDtoType) {
    const { title, slug, content, authorId } = createPostDto;

    const [existingPostWithSameSlug] = await this.db
      .select({ slug: posts.slug })
      .from(posts)
      .where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
      .limit(1);

    if (existingPostWithSameSlug) {
      throw new TRPCError({
        code: "CONFLICT",
        message: "A post with that slug already exists",
      });
    }

    const [createdPost] = await this.db
      .insert(posts)
      .values({ title, slug, content, authorId, publishedAt: new Date() })
      .returning({
        id: posts.id,
        title: posts.title,
        slug: posts.slug,
        content: posts.content,
        authorId: posts.authorId,
      });

    return createdPost;
  }

  async findAll(findAllPostsDto: FindAllPostsDtoType) {
    const { authorId } = findAllPostsDto;

    const postsFetched = await this.db
      .select({ title: posts.title, slug: posts.slug, content: posts.content })
      .from(posts)
      .where(eq(posts.authorId, authorId));

    return postsFetched;
  }

  async findOne(findOnePostDto: FindOnePostDtoType) {
    const { authorId, slug } = findOnePostDto;

    const [post] = await this.db
      .select({
        title: posts.title,
        slug: posts.slug,
        content: posts.content,
      })
      .from(posts)
      .where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
      .limit(1);

    if (!post) {
      return null;
    }

    return post;
  }
}

Файл DTO:

// apps/api/src/posts/posts.dto.ts

import z from "zod";

export const CreatePostDto = z.object({
  title: z.string().min(1).max(150),
  slug: z.string().min(1).max(150),
  content: z.string().min(1),
});

export const FindOnePostDto = z.object({
  slug: z.string().min(1).max(150),
});

type AuthorId = { authorId: string };
export type CreatePostDtoType = z.infer<typeof CreatePostDto> & AuthorId;
export type FindAllPostsDtoType = AuthorId;
export type FindOnePostDtoType = z.infer<typeof FindOnePostDto> & AuthorId;

И модуль, который, как и модуль users, импортирует модуль drizzle:

// apps/api/src/posts/posts.module.ts

import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";

import { PostsService } from "./posts.service";

@Module({
  imports: [DrizzleModule],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}

Аутентификация

Модуль аутентификации предоставляет базовые методы входа и регистрации, а маршрутизатор делает их доступными в виде процедур, которые может вызывать клиент. Сначала необходимо его сгенерировать:

nest g res auth

В этом сервисе реализована основная логика:

// apps/api/src/auth/auth.service.ts

import * as bcrypt from "bcrypt";

import { JWTDtoType } from "@/common/dto";
import { UsersService } from "@/users/users.service";
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { TRPCError } from "@trpc/server";

import { LoginDtoType, RegisterDtoType } from "./auth.dto";

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
    private readonly usersService: UsersService,
  ) {}

  async register(registerDto: RegisterDtoType) {
    const { username, email, password } = registerDto;

    const createdUser = await this.usersService.create({
      email,
      username,
      password,
    });

    const payload: Pick<JWTDtoType, "sub"> = { sub: createdUser.id };
    const token = await this.jwtService.signAsync(payload);

    return { accessToken: token };
  }

  async login(loginDto: LoginDtoType) {
    const { email, password } = loginDto;

    const user = await this.usersService.findByEmail({ email });

    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }

    const isPasswordCorrect = await bcrypt.compare(
      password,
      user.hashedPassword,
    );
    if (!isPasswordCorrect) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Incorrect password",
      });
    }

    const payload: Pick<JWTDtoType, "sub"> = { sub: user.id };
    const token = await this.jwtService.signAsync(payload);

    return {
      accessToken: token,
    };
  }
}

Файл DTO проверяет входные данные:

// apps/api/src/auth/auth.dto.ts

import z from "zod";

export const RegisterDto = z.object({
  username: z.string().min(1).max(25),
  email: z.email(),
  password: z.string().min(1).max(72),
});

export const LoginDto = z.object({
  email: z.email(),
  password: z.string().min(1).max(72),
});

export type RegisterDtoType = z.infer<typeof RegisterDto>;
export type LoginDtoType = z.infer<typeof LoginDto>;

Роутер определяет сами процедуры tRPC:

// apps/api/src/auth/auth.router.ts

import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";

import { LoginDto, RegisterDto } from "./auth.dto";
import { AuthService } from "./auth.service";

@Injectable()
export class AuthRouter {
  constructor(
    private readonly trpcService: TrpcService,
    private readonly authService: AuthService,
  ) {}

  private readonly AUTH_COOKIE_NAME = "auth_token";
  private readonly COOKIE_MAX_AGE = 2 * 24 * 60 * 60 * 1000; // 2 days

  procedures() {
    return {
      auth: this.trpcService.trpc.router({
        register: this.trpcService
          .publicProcedure()
          .input(RegisterDto)
          .mutation(async ({ ctx, input }) => {
            const { accessToken } = await this.authService.register(input);

            // Set the HTTP-only cookie
            ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
              httpOnly: true,
              secure: true,
              sameSite: "lax",
              maxAge: this.COOKIE_MAX_AGE,
            });
          }),

        login: this.trpcService
          .publicProcedure()
          .input(LoginDto)
          .mutation(async ({ ctx, input }) => {
            const { accessToken } = await this.authService.login(input);

            // Set the HTTP-only cookie
            ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
              httpOnly: true,
              secure: true,
              sameSite: "lax",
              maxAge: this.COOKIE_MAX_AGE,
            });
          }),
      }),
    };
  }
}

Наконец, модуль связывает всё воедино:

// apps/api/src/auth/auth.module.ts

import { EnvModule } from "@/env/env.module";
import { EnvService } from "@/env/env.service";
import { TrpcService } from "@/trpc/trpc.service";
import { UsersModule } from "@/users/users.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";

import { AuthRouter } from "./auth.router";
import { AuthService } from "./auth.service";

@Module({
  imports: [
    ConfigModule,
    UsersModule,
    JwtModule.registerAsync({
      imports: [EnvModule],
      useFactory: async (envService: EnvService) => ({
        global: true,
        secret: envService.get("JWT_SECRET"),
        signOptions: { expiresIn: "2d" },
      }),
      inject: [EnvService],
    }),
  ],

  providers: [AuthService, AuthRouter, TrpcService, EnvService],
  exports: [AuthRouter, AuthService],
})
export class AuthModule {}

Недостаточно иметь процедуры в auth.router.ts, поскольку их также необходимо зарегистрировать в основном маршрутизаторе tRPC. Обновите trpc.router.ts:

// apps/api/src/trpc/trpc.router.ts

  appRouter = this.trpcService.trpc.router({
    getHello: this.trpcService
      .publicProcedure()
      .input(
        z.object({
          name: z.string().min(1),
        }),
      )
      .query(({ input }) => `Hello, ${input.name} from trpc server`),

    ...this.authRouter.procedures(),
  });

Файл trpc.module.ts также требует модуля аутентификации:

// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule],
  providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
  exports: [TrpcService],
})

Процедуры для постов применяются аналогичным образом, через posts.router.ts:

// apps/api/src/posts/posts.router.ts

import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";

import { CreatePostDto, FindOnePostDto } from "./posts.dto";
import { PostsService } from "./posts.service";

@Injectable()
export class PostsRouter {
  constructor(
    private readonly trpcService: TrpcService,
    private readonly postsService: PostsService,
  ) {}

  procedures() {
    return {
      posts: this.trpcService.trpc.router({
        create: this.trpcService
          .protectedProcedure()
          .input(CreatePostDto)
          .mutation(async ({ ctx, input }) =>
            this.postsService.create({ ...input, authorId: ctx.user.sub }),
          ),

        findAll: this.trpcService
          .protectedProcedure()
          .query(async ({ ctx }) =>
            this.postsService.findAll({ authorId: ctx.user.sub }),
          ),

        findOne: this.trpcService
          .protectedProcedure()
          .input(FindOnePostDto)
          .query(async ({ ctx, input }) =>
            this.postsService.findOne({ ...input, authorId: ctx.user.sub }),
          ),
      }),
    };
  }
}

Их также необходимо зарегистрировать в маршрутизаторе tRPC:

// apps/api/src/trpc/trpc.router.ts

  appRouter = this.trpcService.trpc.router({
    getHello: this.trpcService
      .publicProcedure()
      .input(
        z.object({
          name: z.string().min(1),
        }),
      )
      .query(({ input }) => `Hello, ${input.name} from trpc server`),

    ...this.authRouter.procedures(),
    ...this.postsRouter.procedures(),
  });

И модуль PostsModule необходимо импортировать в TrpcModule:

// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule, PostsModule],
  providers: [
    TrpcService,
    TrpcRouter,
    JwtService,
    EnvService,
    AuthRouter,
    PostsRouter,
  ],
  exports: [TrpcService],
})

Фронтенд

После создания API внимание переключается на панель управления, React Router и приложение Vite. В нём предусмотрено пять маршрутов: /login, /register, /posts, /posts/:slug и /posts/create. Посетитель может зарегистрироваться и войти, затем создавать и читать собственные посты, причём каждое из этих действий вызывает процедуру, определённую в API. Форма входа показывает, как это выглядит в коде:

import { Controller, useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";

import { useTRPC } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, type LoginSchemaType } from "@repo/common/types-schemas";
import { Button } from "@repo/ui/components/ui/button";
import { Field, FieldError, FieldLabel } from "@repo/ui/components/ui/field";
import { Input } from "@repo/ui/components/ui/input";
import { useMutation } from "@tanstack/react-query";

import { useTogglePassword } from "../hooks/use-toggle-password";

export const LoginForm = () => {
  const form = useForm<LoginSchemaType>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const { isPasswordVisible, EyeIconComponent } = useTogglePassword();

  const navigate = useNavigate();
  const trpc = useTRPC();
  const loginMutation = useMutation(trpc.auth.login.mutationOptions());

  const onSubmit = (values: LoginSchemaType) => {
    loginMutation.mutate(
      {
        email: values.email,
        password: values.password,
      },
      {
        onError: (error) => {
          const errorMessage = error.message;

          if (errorMessage.toLowerCase().includes("email")) {
            form.setError("email", { message: errorMessage });
          } else if (errorMessage.toLowerCase().includes("password")) {
            form.setError("password", { message: errorMessage });
          } else {
            form.setError("root", { message: errorMessage });
          }
        },

        onSuccess: () => {
          toast.success("Login complete");

          navigate("/posts");
        },
      },
    );
  };

  return (
    <form
      onSubmit={form.handleSubmit(onSubmit)}
      className="flex w-[clamp(40%,30rem,80%)] flex-col gap-4 p-2"
    >
      <h2 className="flex flex-col items-center justify-center gap-2 text-center text-3xl font-extrabold max-[42.5rem]:text-2xl">
        Login
      </h2>

      <Controller
        control={form.control}
        name="email"
        render={({ field, fieldState }) => (
          <Field data-invalid={fieldState.invalid}>
            <FieldLabel htmlFor={field.name}>Email</FieldLabel>
            <Input
              id={field.name}
              placeholder="example@email.com"
              type="email"
              aria-invalid={fieldState.invalid}
              className="border-border h-10"
              {...field}
            />
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </Field>
        )}
      />

      <Controller
        control={form.control}
        name="password"
        render={({ field, fieldState }) => (
          <Field data-invalid={fieldState.invalid}>
            <FieldLabel htmlFor={field.name}>Password</FieldLabel>
            <div className="relative flex items-center justify-end">
              <Input
                id={field.name}
                placeholder="***********"
                type={isPasswordVisible ? "text" : "password"}
                aria-invalid={fieldState.invalid}
                className="border-border h-10"
                {...field}
              />
              <EyeIconComponent />
            </div>
            {fieldState.invalid && <FieldError errors={[fieldState.error]} />}
          </Field>
        )}
      />
      <p>
        Not registered yet?{" "}
        <Link to="/register" className="underline">
          Register
        </Link>
      </p>

      <Button
        variant="default"
        type="submit"
        disabled={form.formState.isSubmitting}
        className="h-10 w-1/2 self-center rounded-lg"
      >
        Submit
      </Button>

      <div className="h-4">
        {form.formState.errors.root && (
          <FieldError errors={[form.formState.errors.root]} />
        )}
      </div>
    </form>
  );
};

Отсюда структура может развиваться насколько это необходимо. Новые процедуры на серверной части появляются на клиентской части с полной типобезопасностью, и именно в этом заключается смысл всей этой схемы.