This article is the part 2 of my previous blog post on my monorepo setup. In that part, I showed how I get end to end type safe typescript applications using Nest.js with trpc on the backend and with two frontends (a React Router+Vite SPA and an astro app) in a monorepo powered by turborepo. This time, we will integrate a PostgreSQL database using drizzle-orm, some basic authentication and private trpc procedures to get and create data requiring the user to be logged in. Here is a brief overview of the monorepo we had before:
And now what we will get after integrating our database:
For this blog post, the full source code is also available at: monorepo-setup, on the database-and-auth branch.
Let’s start by adding a PostgreSQL database using docker compose. Here is the compose.yaml file at the root of the monorepo to spin it up:
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
This will pull the docker image of postgreSQL 17 (alpine version) if it is not already present, create a database container called monorepo-setup-database-container, expose it on the port 5432 on localhost. And the data is persisted using a named docker volume. We need a .env.database to start the container correctly, for example:
# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
Then start the database container with:
docker compose up -d --remove-orphans
And to check that everything is working as expected, run:
docker ps
We should be able to see our healthy database container:
Let’s also confirm that it has created an empty database with the following command:
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)
It drops us in a PostgreSQL console and we can check the existing tables with \dt:
We have no tables for the moment, which is exactly what we want. The next step is the installation and configuration of drizzle-orm in the api.
First cd in the api folder
# From the root of the monorepo
cd apps/api
After run the following commands:
npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg
to install the necessary dependencies.
And add the DATABASE_URL environment variable in the .env file of our api. It has the following shape:
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>
We will replace user with our POSTGRES_USER, password with POSTGRES_PASSWORD, host with localhost, port with 5432 specified in our compose.yaml file and database with POSTGRES_DB. So, for example we get in our case:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup
To configure drizzle-orm in the Nest.js way, we need a new module. Still in the api:
nest g res drizzle
After getting rid of the boilerplate, we only need some files
-
constants.ts
// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";
-
drizzle.provider.ts
// 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 });
},
};
- a users table:
users.ts
// 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(),
});
- a posts table:
posts.ts
// 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(),
});
- a root schema:
// apps/api/src/drizzle/schema/index.ts
export * from "./posts";
export * from "./users";
We will be using ULIDs for the ids. So let’s install the node ulid package, still in the api
npm i ulid
The posts and users table have a one-to-many relationship: one user (or author) can create many posts, and one post can only belong to one user.
In the part 1 on this article, we used a Env module to have type safe environment variables, so we need to add our DATABASE_URL to the set of registered environment variables:
// 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>;
And to conclude that config part, we need a drizzle.config.ts file in the root of our 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!,
},
});
This tells drizzle to generate the migrations in a migrations folder in our drizzle module directory and uses the index schema. To run the migrations, we need to add two scripts in the package.json file of our api folder:
// apps/api/package.json
{
"scripts": {
...
"generate": "npx drizzle-kit generate",
"migrate": "npx drizzle-kit migrate",
...
}
}
We can finally generate and apply our migrations:
npm run generate
which will create a migration.sql file in our migrations folder. Followed by:
npm run generate
The tables users and posts should have been created in our database. We can check that with either the same way as before,
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
followed by
\dt
And we have the two tables:
Or use drizzle-studio by adding the following script in our package.json
// apps/api/package.json
{
"scripts": {
...
"studio": "npx drizzle-kit studio"
...
}
}
And run
npm run studio
Then open drizzle studio:
We now have our database properly connected to the api using drizzle-orm
Side note: the drizzle.module.ts should look like:
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 {}
and the 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 {}
We will now proceed to setup a users and posts modules with their services containing some basic crud methods
- users
From the api:
nest g res users
And get rid of the boilerplate. The users.service.ts file:
// 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;
}
}
We have a users.dto.ts file the zod schemas and types of the expected input for each method:
// 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>;
We will also need to install bcrypt for passwords hashing:
# apps/api
npm i bcrypt
npm i -D @types/bcrypt
And import the drizzle module in the users.module.ts file:
// 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 {}
- posts
Still from the api
nest g res posts
As always, get rid of the boilerplate and only keep some important files, our posts.service.ts:
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;
}
}
The dto file here:
// 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;
And finally the module where similarly to the users.module.ts file, we need to import the drizzle module
// 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 {}
Now let’s add an authentication module with some basic login and register methods then a router that will expose those procedures (login and register) so we will be able to call them from the client.
nest g res auth
Our keys files here are:
-
auth.service.ts
// 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,
};
}
}
-
auth.dto.ts
// 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>;
-
auth.router.ts
// 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,
});
}),
}),
};
}
}
and to finish, the auth.module.ts
// 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 {}
The procedures are exposed through the auth.router.ts file, but now we need to register them at the level of the trpc router. To do so, we have to update the trpc.router.ts file
// 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(),
});
And the trpc.module.ts file too:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule],
providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
exports: [TrpcService],
})
In the same way, let’s add some procedures for the posts too, through a posts.router.ts file:
// 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 }),
),
}),
};
}
}
And we don’t forget to register those methods in the trpc router:
// 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(),
});
As to import the PostsModule in the TrpcModule:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule, PostsModule],
providers: [
TrpcService,
TrpcRouter,
JwtService,
EnvService,
AuthRouter,
PostsRouter,
],
exports: [TrpcService],
})
Now that we are pretty much setup on the api side, let’s switch on the frontend. For simplicity, I will be focusing on the dashboard (React Router+Vite app). We will have five routes: two authentication routes (/login and /register), a /posts route, a /posts/:slug route and a /posts/create route.
/login
/register
/posts
/posts/:slug
/posts/create
The user can register and login, create and view his posts. All this happen by calling the procedures that we defined on our api. For example, the login form looks like this in the code:
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>
);
};
We can extend as much as we want: add new procedures on our backend and call them from the frontend in a type safe way. This was the part two of my monorepo setup to ship end to end type safe typescript applications.
Thanks for reading and happy coding!










Top comments (0)