Build a Bookmark Manager REST API

Build a full CRUD REST API with NestJS, Prisma 7, and PostgreSQL.

Introduction

30 Second Summary

You probably have dozens of links saved across browser tabs, sticky notes, and messaging apps. When you actually need one of them, good luck finding it.

In this project, you will build a Bookmark Manager REST API that stores, organizes, and retrieves web links from a PostgreSQL database. You'll use NestJS as the backend framework and Prisma 7 as the database toolkit.

What You'll Build

You'll fire off HTTP requests from your terminal, and your API responds with neatly structured JSON. Save a link, tag it with a category, retrieve it later, or delete it when it's no longer useful.

By the end of this project, you'll have:

  • A running REST API that creates, reads, updates, and deletes bookmarks stored in PostgreSQL, all testable with curl commands.
  • Input validation that rejects requests with missing titles or malformed URLs, returning clear error messages instead of raw database crashes.
  • Category-based organization so you can filter bookmarks by topic with a single query parameter.
  • Secret Mission: Add pagination and search so your API handles hundreds of bookmarks without breaking a sweat.

Are there any prerequisites?

This project assumes you have JavaScript knowledge and the following tools already installed: Node.js v20 or higher, PostgreSQL, and Cursor. Step 1 walks you through installing the NestJS CLI and project-level packages.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up NestJS Project

You need a running application before you can build any endpoints on top of it. NestJS provides a CLI that scaffolds a fully structured project in seconds.

In this step, you will install the NestJS CLI, scaffold a new project called bookmark-api, and confirm the development server responds in your browser.

In this step, get ready to:
  • Install the NestJS CLI and scaffold a new project.
  • Explore the generated project structure.
  • Start the development server and confirm it responds.
Install the NestJS CLI and scaffold your project
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
  • Install the NestJS CLI globally by running this command:
npm i -g @nestjs/cli

You should see npm download and install the package, ending with a summary of added packages.

Seeing a permission error?

On macOS or Linux, you may need to prefix the command with sudo. On Windows, try running your terminal as Administrator.

help me fix a permission error when installing npm packages globally

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your new NestJS project by running this command:
nest new bookmark-api

What will this command ask me?

The CLI will prompt you to choose a package manager (npm, yarn, or pnpm). Select npm using the arrow keys and press Enter.

After you confirm, the CLI creates a bookmark-api folder, generates the project files, and installs all dependencies. This takes about a minute.

  • Move into your new project folder by running:
cd bookmark-api
  • Confirm the project files were created by running:
ls src

You should see app.controller.ts, app.module.ts, app.service.ts, and main.ts listed in the output.

Explore the generated project structure
  • Open the bookmark-api folder in Cursor by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the bookmark-api folder, and click Open.

In the Explorer sidebar on the left, expand the src folder. You will see four core files that NestJS generated for you.

  • Click on src/main.ts to open it.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

What does this file do?

This is the entry point of your application. It creates a NestJS app from the root AppModule and starts listening on port 3000.

  • Click on src/app.module.ts to open it.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

What is a module in NestJS?

A module groups related controllers and services together. The @Module() decorator tells NestJS which controllers handle routes and which services provide business logic.

Every NestJS app has at least one module (the root module). As you build features, you will create additional modules to keep your code organized.

  • Click on src/app.controller.ts to open it.
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

What is a controller?

Controllers handle incoming HTTP requests. The @Get() decorator maps the getHello() method to a GET request at the root path (/).

Notice how the controller calls this.appService.getHello() instead of building the response itself. This separation is called dependency injection. The controller delegates work to a service.

  • Click on src/app.service.ts to open it.
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

What is a service?

Services contain your business logic. The @Injectable() decorator marks the class so NestJS can inject it into controllers or other services that need it.

This pattern keeps route handling (controllers) separate from logic (services). You will use this same pattern for your bookmarks feature.

Start the development server
  • Open a new terminal in Cursor by pressing Ctrl+`.
  • Start the development server by running this command:
npm run start:dev

What does start:dev do?

This command starts NestJS in watch mode. It compiles your TypeScript and automatically restarts the server whenever you save a file.

Before you check your browser, what do you think you will see at http://localhost:3000?

  • Open your browser and navigate to http://localhost:3000.

You should see Hello World! displayed as plain text. That response came from your AppService, through AppController, to your browser.

Not seeing Hello World?

Check your terminal for error messages. If you see a port conflict (EADDRINUSE), another process is already using port 3000. Stop that process or close the other terminal.

Make sure you ran the command inside the bookmark-api folder, not your Desktop.

help me troubleshoot my NestJS dev server not responding at localhost:3000

Your NestJS application is up and running. Next up, you will connect it to a PostgreSQL database using Prisma 7 so your bookmarks have a place to live.

Configure Prisma 7 with PostgreSQL

Your NestJS app is running and ready for requests. Now it needs somewhere to store data.

In this step, you will connect your app to a PostgreSQL database using Prisma 7. Prisma 7 uses a new driver adapter pattern and generates its client directly into your source code instead of hiding it inside node_modules. This step gets the database layer working so the next step can build endpoints on top of it.

In this step, get ready to:
  • Install Prisma packages and initialize the schema file.
  • Define a Bookmark model and run your first migration.
  • Create a PrismaService and PrismaModule that the rest of your app can use.
Install and initialize Prisma

Before Prisma can talk to your database, you need three packages and a schema file that tells Prisma how to connect.

  • Stop the dev server by pressing Ctrl+C in the terminal where it is running.
  • Install the Prisma CLI as a dev dependency and the runtime packages by running these commands:
npm install prisma --save-dev
npm install @prisma/client @prisma/adapter-pg

What are these packages?

`prisma` is the CLI tool that generates code and runs migrations. It only runs during development, so it goes in devDependencies.

`@prisma/client` is the type-safe query builder your app uses at runtime. `@prisma/adapter-pg` is the PostgreSQL driver adapter that Prisma 7 requires to connect to your database.

  • Initialize Prisma in your project by running:
npx prisma init

You should see output confirming that Prisma created a prisma/ directory with a schema.prisma file, plus a prisma.config.ts file in your project root.

  • Confirm the new files exist by running:
ls prisma/

You should see schema.prisma listed in the output.

Don't see schema.prisma?

Make sure you ran npx prisma init from inside the bookmark-api folder. If you are in a parent directory, cd bookmark-api first and try again.

Help me debug my Prisma init

The generated schema already has the correct provider and output values, but it is missing the moduleFormat setting and the database URL reference that NestJS needs.

  • Open prisma/schema.prisma in Cursor and replace its entire contents with the following:
generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Why moduleFormat cjs?

Prisma 7 generates ESM code by default. NestJS uses CommonJS. Setting moduleFormat = "cjs" tells Prisma to generate a CommonJS client that NestJS can import without errors.

The output path places the generated client inside your src/ directory so TypeScript can resolve it with a standard relative import.

  • Open the .env file in your project root and replace its contents with your PostgreSQL connection string:
DATABASE_URL="postgresql://your-username:your-password@localhost:5432/bookmark_api"
  • Replace your-username and your-password with your actual PostgreSQL credentials.
  • Save both files.

Find your PostgreSQL credentials

On macOS, the default username is often your system username with no password. On Windows, you set a password during installation. If you are unsure, try postgres as the username.

Define the Bookmark model and run the migration

Your schema knows how to connect, but it has no tables yet. You need a model that describes the shape of a bookmark in the database.

  • Open prisma/schema.prisma and add the following Bookmark model below the datasource block:
model Bookmark {
  id          Int       @id @default(autoincrement())
  title       String
  url         String
  description String?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

What does this model do?

`id` is an auto-incrementing integer that serves as the primary key. `title` and `url` are required strings. `description` is optional (the `?` makes it nullable).

`createdAt` records when the row was inserted. `updatedAt` automatically refreshes every time the row changes.

  • Save prisma/schema.prisma.
  • Create the database table by running:
npx prisma migrate dev --name init

This command reads your model, generates a SQL migration file, and executes it against your PostgreSQL database. You should see output mentioning the migration name init and confirmation that it was applied.

Seeing a connection error?

Double-check that PostgreSQL is running on your machine. Verify the username, password, and port in your .env file match your local PostgreSQL setup.

If the database bookmark_api does not exist yet, Prisma will attempt to create it. If your user lacks permission, create it manually with createdb bookmark_api in a separate terminal.

Help me fix my Prisma database connection

  • Generate the Prisma client by running:
npx prisma generate

Why a separate generate step?

In Prisma 7, migrate dev no longer auto-runs generate. You must run npx prisma generate explicitly to produce the TypeScript client code your app imports.

Before moving on, predict what you will see when you open Prisma Studio. Will there be data in the Bookmark table?

  • Launch Prisma Studio by running:
npx prisma studio

Your browser opens at http://localhost:5555 showing your database tables. Click the Bookmark table. It is empty because you have not created any records yet. That confirms your schema and migration worked.

  • Close Prisma Studio by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Make sure you have saved prisma/schema.prisma and .env.

ⓧ I'd like to double check the full code

Here is the full prisma/schema.prisma at this point:

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Bookmark {
  id          Int       @id @default(autoincrement())
  title       String
  url         String
  description String?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

And the .env file:

DATABASE_URL="postgresql://your-username:your-password@localhost:5432/bookmark_api"
Create the PrismaService and PrismaModule

You have a generated client, but NestJS does not know it exists. You need a service that wraps the client and a module that makes it available across the entire app.

  • Create a new folder and file at src/prisma/prisma.service.ts and paste the following code:
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  constructor() {
    // Create a PostgreSQL adapter using the connection string
    const adapter = new PrismaPg({
      connectionString: process.env.DATABASE_URL,
    });
    // Pass the adapter to PrismaClient
    super({ adapter });
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

What does this service do?

PrismaService extends PrismaClient, which means it has all the query methods (create, findMany, etc.) built in. The PrismaPg adapter tells Prisma to use the native PostgreSQL driver with your connection string.

The OnModuleInit and OnModuleDestroy lifecycle hooks connect to the database when the app starts and disconnect cleanly when it shuts down.

  • Create src/prisma/prisma.module.ts with the following code:
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Why the @Global decorator?

The @Global() decorator makes PrismaService available to every module in your app without needing to import PrismaModule everywhere. You register it once in the root module and every controller or service can inject it.

  • Open src/app.module.ts and replace its contents with:
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [PrismaModule],
})
export class AppModule {}

Where did AppController and AppService go?

Those were the default "Hello World" scaffolding. You do not need them anymore because you will build dedicated bookmarks endpoints in the next step. Removing them keeps AppModule clean.

  • Save all three files.
  • Start the dev server again to verify everything compiles:
npm run start:dev

You should see NestJS boot without errors. The log shows Nest application successfully started confirming that PrismaModule loaded and connected to your database.

Seeing a compile error?

If you see an error about ../generated/prisma/client not being found, make sure you ran npx prisma generate earlier. That command creates the src/generated/prisma/ folder.

If you see an error referencing AppController, double-check that you removed the old imports from src/app.module.ts.

Help me fix my NestJS compile error after adding Prisma

✔️ Awesome, I've got everything!

Great. Confirm your dev server shows no errors in the terminal.

ⓧ I'd like to double check the full code

Here is src/prisma/prisma.service.ts:

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '../generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  constructor() {
    const adapter = new PrismaPg({
      connectionString: process.env.DATABASE_URL,
    });
    super({ adapter });
  }

  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

Here is src/prisma/prisma.module.ts:

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Here is src/app.module.ts:

import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';

@Module({
  imports: [PrismaModule],
})
export class AppModule {}

Your database layer is complete. Prisma is connected, your Bookmark table exists, and PrismaService is ready to be injected into any service you build next.

Next up, you will create actual API endpoints that use PrismaService to create and retrieve bookmarks from the database.

Build Your First Bookmarks Endpoint

You have a database table ready and waiting, but no way for anyone to actually store or retrieve bookmarks from it.

In this step, you will create your first working API endpoints. By the end, you will be able to send a POST request to create a bookmark and a GET request to retrieve all bookmarks as JSON. This proves your NestJS app and PostgreSQL database are talking to each other.

In this step, get ready to:
  • Generate a bookmarks module, controller, and service using the NestJS CLI.
  • Inject PrismaService and implement create and findAll methods.
  • Wire up POST and GET routes and test them with curl.
Generate the bookmarks module, controller, and service

NestJS organizes features into self-contained modules. Each module gets its own controller (handles HTTP routes) and service (handles business logic). The CLI can generate all three files and wire them together automatically.

  • Generate the bookmarks module by running this command:
nest generate module bookmarks

The CLI creates src/bookmarks/bookmarks.module.ts and automatically registers it in app.module.ts.

  • Generate the controller by running:
nest generate controller bookmarks --no-spec
  • Generate the service by running:
nest generate service bookmarks --no-spec

What does --no-spec do?

The --no-spec flag tells the CLI to skip generating test files. This keeps your project folder clean while you focus on building the API.

  • Confirm the generated files exist by running:
ls src/bookmarks

You should see bookmarks.controller.ts, bookmarks.module.ts, and bookmarks.service.ts listed.

How does the CLI wire modules together?

When you ran nest generate module bookmarks, the CLI added BookmarksModule to the imports array of your root AppModule. The controller and service commands then registered those classes inside BookmarksModule's controllers and providers arrays.

This chain means NestJS knows about your bookmarks feature without you manually editing any module files.

Command not found: nest?

If you see a "command not found" error, the NestJS CLI is not installed globally. Run npm i -g @nestjs/cli and try again.

help me fix the nest command not found error

Implement the bookmarks service

The generated service file is empty. You need to inject PrismaService and add methods that talk to the database.

  • Open src/bookmarks/bookmarks.service.ts in Cursor and replace its entire contents with the following code:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

  // Create a new bookmark in the database
  async create(data: any) {
    return this.prisma.bookmark.create({ data });
  }

  // Retrieve all bookmarks from the database
  async findAll() {
    return this.prisma.bookmark.findMany();
  }
}

What does this code do?

The constructor(private prisma: PrismaService) line uses dependency injection. NestJS automatically provides a PrismaService instance when it creates the service. You declared PrismaModule as global in the previous step, so any module can access it.

The create method calls prisma.bookmark.create() to insert a row. The findAll method calls prisma.bookmark.findMany() to fetch every bookmark in the table.

  • Save bookmarks.service.ts.
Wire up the controller and test your endpoints

The controller is the layer that receives HTTP requests and passes them to the service. You need to add a POST route to create bookmarks and a GET route to list them.

  • Open src/bookmarks/bookmarks.controller.ts in Cursor and replace its entire contents with the following code:
import { Controller, Get, Post, Body } from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  // POST /bookmarks - create a new bookmark
  @Post()
  create(@Body() data: any) {
    return this.bookmarksService.create(data);
  }

  // GET /bookmarks - retrieve all bookmarks
  @Get()
  findAll() {
    return this.bookmarksService.findAll();
  }
}

What does this code do?

The @Controller('bookmarks') decorator tells NestJS that all routes in this class live under the /bookmarks path.

The @Post() decorator maps the create method to POST /bookmarks. The @Body() decorator extracts the JSON body from the request and passes it to the method as data. The @Get() decorator maps findAll to GET /bookmarks.

  • Save bookmarks.controller.ts.
  • Make sure your dev server is still running. If it stopped, start it again by running:
npm run start:dev

The dev server auto-reloads when you save files. You should see log output confirming the routes are mapped.

Before you test, what do you think will happen when you POST a bookmark with a title and URL? Will you get that same data back?

  • Open a new terminal and create your first bookmark by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"NestJS Docs","url":"https://docs.nestjs.com"}'

You should see a JSON response containing your bookmark with an auto-generated id, createdAt, and updatedAt timestamp.

  • Retrieve all bookmarks by running:
curl http://localhost:3000/bookmarks

You should see a JSON array containing the bookmark you just created.

Getting a 500 error or empty response?

Check that your DATABASE_URL in .env has the correct username, password, and database name. Also confirm that PostgreSQL is running on your machine.

If you see a "Cannot find module" error, make sure the import path in bookmarks.service.ts uses '../prisma/prisma.service' with the correct relative path.

help me debug my bookmarks endpoint returning a 500 error

✔️ Awesome, I've got everything!

Great. Double check you have saved both bookmarks.service.ts and bookmarks.controller.ts.

ⓧ I'd like to double check the full code

Here is the full content of each file you modified in this step. Compare yours against these.

src/bookmarks/bookmarks.service.ts:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

  async create(data: any) {
    return this.prisma.bookmark.create({ data });
  }

  async findAll() {
    return this.prisma.bookmark.findMany();
  }
}

src/bookmarks/bookmarks.controller.ts:

import { Controller, Get, Post, Body } from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() data: any) {
    return this.bookmarksService.create(data);
  }

  @Get()
  findAll() {
    return this.bookmarksService.findAll();
  }
}

src/bookmarks/bookmarks.module.ts (auto-generated by CLI):

import { Module } from '@nestjs/common';
import { BookmarksController } from './bookmarks.controller';
import { BookmarksService } from './bookmarks.service';

@Module({
  controllers: [BookmarksController],
  providers: [BookmarksService],
})
export class BookmarksModule {}

src/app.module.ts (updated by CLI):

import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { BookmarksModule } from './bookmarks/bookmarks.module';

@Module({
  imports: [PrismaModule, BookmarksModule],
})
export class AppModule {}

Your API can now create and retrieve bookmarks from PostgreSQL. Next up, you will add input validation so the API rejects bad data instead of crashing with raw database errors.

Add Validation with DTOs and Pipes

Your API is running and you can create bookmarks. But what happens if someone sends a request with no title, or a completely empty body?

Right now, invalid data passes straight through to Prisma, which throws a raw database error. That leaks internal details about your schema and confuses anyone using your API. In this step, you will add a validation layer using DTOs (Data Transfer Objects) and the built-in ValidationPipe to catch bad input before it reaches the database.

In this step, get ready to:
  • Install validation packages and enable the global ValidationPipe.
  • Create DTOs with decorator-based validation rules.
  • Complete full CRUD with findOne, update, and delete routes.
Install validation packages and enable the ValidationPipe

Before you fix anything, see the problem firsthand. Your dev server should still be running from the previous step.

  • Before you run this, do you think the request will succeed or fail? What kind of error would you expect?
  • Send an empty POST request to your bookmarks endpoint by running this command in a new terminal:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{}'

You see a raw Prisma database error with internal details about your schema. This is the gap you are about to close.

Why is this a problem?

The error message exposes database column names and constraint details to the caller. In a production API, this is a security risk and a poor developer experience. Validation should catch bad input early and return clear, actionable messages.

  • Install the validation packages by running this command:
npm install class-validator class-transformer @nestjs/mapped-types

What are these packages?

The class-validator package provides decorators like @IsNotEmpty() and @IsUrl() that define validation rules on class properties. The class-transformer package converts plain JSON objects into typed class instances so the decorators can run.

The @nestjs/mapped-types package provides utilities like PartialType for creating DTO variants without duplicating code.

  • Open src/main.ts in Cursor and replace its contents with the following code to enable the global ValidationPipe:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // Enable validation for all incoming requests
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );
  await app.listen(3000);
}
bootstrap();

What do whitelist and transform do?

Setting whitelist: true strips any properties from the request body that are not defined in the DTO. This prevents attackers from injecting unexpected fields.

Setting transform: true automatically converts the plain JSON object into an instance of your DTO class, so the validation decorators can execute.

  • Save src/main.ts.
  • Check the terminal where your dev server is running. It should restart automatically without errors.

Server crashed after saving?

Make sure you imported ValidationPipe from @nestjs/common on the second line. A missing import causes a ReferenceError at startup.

Help me debug

Create DTOs for type-safe validation

A DTO defines the exact shape of data your endpoint accepts. Validation decorators on each property tell NestJS what to reject.

  • Create a new folder src/bookmarks/dto by running:
mkdir -p src/bookmarks/dto
  • Create src/bookmarks/dto/create-bookmark.dto.ts with the following code:
import { IsNotEmpty, IsString, IsUrl, IsOptional, IsInt } from 'class-validator';

export class CreateBookmarkDto {
  @IsNotEmpty()
  @IsString()
  title: string;

  @IsNotEmpty()
  @IsUrl()
  url: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsInt()
  categoryId?: number;
}

What do these decorators do?

Each decorator stacks validation rules on the property below it. @IsNotEmpty() rejects empty strings. @IsUrl() checks for a valid URL format. @IsOptional() means the field can be omitted entirely.

The categoryId field is optional and typed as an integer. You will wire it up to a Category model in the next step.

  • Save create-bookmark.dto.ts.
  • Now create src/bookmarks/dto/update-bookmark.dto.ts with the following code:
import { PartialType } from '@nestjs/mapped-types';
import { CreateBookmarkDto } from './create-bookmark.dto';

// PartialType makes all fields from CreateBookmarkDto optional
export class UpdateBookmarkDto extends PartialType(CreateBookmarkDto) {}

What is PartialType?

PartialType from @nestjs/mapped-types takes your CreateBookmarkDto and returns a new class where every property is optional. This means an update request only needs to include the fields being changed.

  • Save update-bookmark.dto.ts.
  • Confirm both files exist by running:
ls src/bookmarks/dto

You should see create-bookmark.dto.ts and update-bookmark.dto.ts listed.

  • Now update src/bookmarks/bookmarks.service.ts to use the typed DTO. Replace the entire file contents with:
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
import { UpdateBookmarkDto } from './dto/update-bookmark.dto';

@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

  // Create a new bookmark from validated DTO data
  async create(dto: CreateBookmarkDto) {
    return this.prisma.bookmark.create({
      data: dto,
    });
  }

  // Retrieve all bookmarks, optionally filtered by category
  async findAll(categoryId?: number) {
    const where = categoryId ? { categoryId } : {};
    return this.prisma.bookmark.findMany({
      where,
      orderBy: { createdAt: 'desc' },
    });
  }
}

What changed in the service?

The data: any parameter is now dto: CreateBookmarkDto. TypeScript enforces that only valid properties reach Prisma. The findAll method accepts an optional categoryId filter and sorts results newest-first.

  • Save bookmarks.service.ts.
  • Now update src/bookmarks/bookmarks.controller.ts to wire the DTOs into the routes. Replace the entire file contents with:
import {
  Controller,
  Get,
  Post,
  Body,
  Query,
} from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() dto: CreateBookmarkDto) {
    return this.bookmarksService.create(dto);
  }

  @Get()
  findAll(@Query('categoryId') categoryId?: string) {
    return this.bookmarksService.findAll(
      categoryId ? parseInt(categoryId, 10) : undefined,
    );
  }
}

What does typing the @Body() parameter do?

When you type @Body() dto: CreateBookmarkDto, the ValidationPipe automatically validates the incoming JSON against your DTO rules. If validation fails, NestJS returns a 400 response before your service code runs.

  • Save bookmarks.controller.ts and wait for the dev server to restart.
  • Test validation by sending the same empty POST request you tried earlier:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{}'

You now see a clean 400 response with specific validation error messages instead of a database crash. The designed obstacle from earlier is fixed.

Still seeing a 500 error instead of validation messages?

Confirm that main.ts has app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })) and that the server restarted after you saved. Also check that the @Body() parameter in the controller is typed as CreateBookmarkDto, not any.

Help me debug my validation setup

Complete CRUD with findOne, update, and delete

Your API can create and list bookmarks with validation. Now add the remaining operations so you can retrieve a single bookmark, update it, and delete it.

  • Open src/bookmarks/bookmarks.service.ts and add the following three methods inside the BookmarksService class, below the findAll method:
  // Find a single bookmark by ID or throw 404
  async findOne(id: number) {
    const bookmark = await this.prisma.bookmark.findUnique({
      where: { id },
    });
    if (!bookmark) {
      throw new NotFoundException(`Bookmark with ID ${id} not found`);
    }
    return bookmark;
  }

  // Update a bookmark after confirming it exists
  async update(id: number, dto: UpdateBookmarkDto) {
    await this.findOne(id);
    return this.prisma.bookmark.update({
      where: { id },
      data: dto,
    });
  }

  // Delete a bookmark after confirming it exists
  async remove(id: number) {
    await this.findOne(id);
    return this.prisma.bookmark.delete({
      where: { id },
    });
  }

What does findOne do with NotFoundException?

The findOne method queries by ID and throws a NotFoundException if no record is found. NestJS catches this and returns a proper 404 JSON response.

Both update and remove call findOne first to ensure the bookmark exists before modifying it.

  • Save bookmarks.service.ts.
  • Now update src/bookmarks/bookmarks.controller.ts to add the new routes. Replace the entire file contents with:
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseIntPipe,
} from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
import { UpdateBookmarkDto } from './dto/update-bookmark.dto';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() dto: CreateBookmarkDto) {
    return this.bookmarksService.create(dto);
  }

  @Get()
  findAll(@Query('categoryId') categoryId?: string) {
    return this.bookmarksService.findAll(
      categoryId ? parseInt(categoryId, 10) : undefined,
    );
  }
}

That covers the existing routes with the new imports. Now add the three new route handlers below the findAll method, still inside the class:

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.bookmarksService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: UpdateBookmarkDto,
  ) {
    return this.bookmarksService.update(id, dto);
  }

  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.bookmarksService.remove(id);
  }
}

What is ParseIntPipe?

URL parameters arrive as strings. ParseIntPipe is a built-in NestJS pipe that converts the string :id parameter to a number. If someone passes a non-numeric ID like /bookmarks/abc, the pipe rejects the request with a 400 error automatically.

  • Save bookmarks.controller.ts and wait for the dev server to restart.
  • Before you test, what do you think will happen if you request a bookmark ID that does not exist?
  • Create a bookmark for testing by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"NestJS Docs","url":"https://docs.nestjs.com"}'
  • Note the id value in the response. Use that ID in the following commands.
  • Retrieve the bookmark by ID:
curl http://localhost:3000/bookmarks/1
  • Update the bookmark title:
curl -X PATCH http://localhost:3000/bookmarks/1 -H "Content-Type: application/json" -d '{"title":"NestJS Official Docs"}'
  • Delete the bookmark:
curl -X DELETE http://localhost:3000/bookmarks/1

Each command returns the bookmark data. The PATCH response shows the updated title, and the DELETE response returns the removed bookmark.

  • Now confirm validation still rejects bad input by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"","url":"not-a-url"}'

You see a 400 response with specific error messages explaining what is wrong. Your API validates all input and returns clear, structured error messages.

Getting a 404 on GET /bookmarks/1?

If you deleted all bookmarks in previous testing, your table is empty. Create a new bookmark with the POST command first, then use the returned id in your GET, PATCH, and DELETE requests.

Help me debug my CRUD endpoints

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files: main.ts, create-bookmark.dto.ts, update-bookmark.dto.ts, bookmarks.service.ts, and bookmarks.controller.ts.

ⓧ I'd like to double check the full code

Here is each file in its complete state. Compare yours against these.

src/main.ts

import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );
  await app.listen(3000);
}
bootstrap();

src/bookmarks/dto/create-bookmark.dto.ts

import { IsNotEmpty, IsString, IsUrl, IsOptional, IsInt } from 'class-validator';

export class CreateBookmarkDto {
  @IsNotEmpty()
  @IsString()
  title: string;

  @IsNotEmpty()
  @IsUrl()
  url: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsInt()
  categoryId?: number;
}

src/bookmarks/dto/update-bookmark.dto.ts

import { PartialType } from '@nestjs/mapped-types';
import { CreateBookmarkDto } from './create-bookmark.dto';

export class UpdateBookmarkDto extends PartialType(CreateBookmarkDto) {}

src/bookmarks/bookmarks.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
import { UpdateBookmarkDto } from './dto/update-bookmark.dto';

@Injectable()
export class BookmarksService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreateBookmarkDto) {
    return this.prisma.bookmark.create({
      data: dto,
    });
  }

  async findAll(categoryId?: number) {
    const where = categoryId ? { categoryId } : {};
    return this.prisma.bookmark.findMany({
      where,
      orderBy: { createdAt: 'desc' },
    });
  }
  async findOne(id: number) {
    const bookmark = await this.prisma.bookmark.findUnique({
      where: { id },
    });
    if (!bookmark) {
      throw new NotFoundException(`Bookmark with ID ${id} not found`);
    }
    return bookmark;
  }

  async update(id: number, dto: UpdateBookmarkDto) {
    await this.findOne(id);
    return this.prisma.bookmark.update({
      where: { id },
      data: dto,
    });
  }

  async remove(id: number) {
    await this.findOne(id);
    return this.prisma.bookmark.delete({
      where: { id },
    });
  }
}

src/bookmarks/bookmarks.controller.ts

import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseIntPipe,
} from '@nestjs/common';
import { BookmarksService } from './bookmarks.service';
import { CreateBookmarkDto } from './dto/create-bookmark.dto';
import { UpdateBookmarkDto } from './dto/update-bookmark.dto';

@Controller('bookmarks')
export class BookmarksController {
  constructor(private readonly bookmarksService: BookmarksService) {}

  @Post()
  create(@Body() dto: CreateBookmarkDto) {
    return this.bookmarksService.create(dto);
  }

  @Get()
  findAll(@Query('categoryId') categoryId?: string) {
    return this.bookmarksService.findAll(
      categoryId ? parseInt(categoryId, 10) : undefined,
    );
  }
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.bookmarksService.findOne(id);
  }

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: UpdateBookmarkDto,
  ) {
    return this.bookmarksService.update(id, dto);
  }

  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.bookmarksService.remove(id);
  }
}

Your API now has full CRUD with proper input validation. Invalid requests get rejected with descriptive error messages, and every route is type-safe. Next up, you will add categories so bookmarks can be organized into groups.

Add Categories with Relations

Your bookmarks API validates input and performs full CRUD. But a flat list of bookmarks gets messy fast.

Real bookmarks need organization. In this step, you will add a Category model with a one-to-many relation to Bookmark. This teaches you how Prisma handles relational data and how NestJS modules stay cleanly separated even when data is connected.

In this step, get ready to:
  • Add a Category model with a one-to-many relation to Bookmark in your Prisma schema.
  • Generate a categories module with full CRUD endpoints.
  • Test filtering bookmarks by category using query parameters.
Add the Category model and relation to your schema

A one-to-many relation means one category can hold many bookmarks, but each bookmark belongs to at most one category.

  • In Cursor, open prisma/schema.prisma.
  • Replace the existing Bookmark model and add the new Category model below the datasource block, so your models section looks like this:
model Bookmark {
  id          Int       @id @default(autoincrement())
  title       String
  url         String
  description String?
  categoryId  Int?
  category    Category? @relation(fields: [categoryId], references: [id])
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}

model Category {
  id        Int        @id @default(autoincrement())
  name      String     @unique
  bookmarks Bookmark[]
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

What does this schema do?

  • categoryId Int? adds an optional foreign key column to the Bookmark table. A bookmark can exist without a category.
  • category Category? @relation(...) tells Prisma how the two models connect. The fields side points to the foreign key, and references points to the primary key on Category.
  • bookmarks Bookmark[] on Category is the reverse side of the relation. It does not create a database column. It lets Prisma include related bookmarks when you query a category.
  • @unique on name prevents duplicate category names.
  • Save prisma/schema.prisma.
  • Apply the schema changes to your database by running this migration command:
npx prisma migrate dev --name add-categories

You should see output confirming the migration was applied successfully and that a new migration file was created in prisma/migrations/.

  • Generate the updated Prisma client by running:
npx prisma generate

Your dev server will recompile. Check the terminal running npm run start:dev for a clean compilation with no errors.

Seeing a relation error in the migration?

Make sure both the categoryId field and the category relation line exist on the Bookmark model. Prisma requires both sides of the relation to be defined.

Also confirm that Category in @relation(fields: [categoryId], references: [id]) matches the model name exactly (case-sensitive).

help me fix a Prisma relation error in my schema

Generate and implement the categories module

Just like bookmarks, categories get their own module, controller, and service. The NestJS CLI scaffolds these and automatically registers the new module in AppModule.

  • Generate the categories module, controller, and service by running these commands:
nest generate module categories
nest generate controller categories --no-spec
nest generate service categories --no-spec

What do these commands do?

Each command creates a file inside src/categories/ and wires it into the module. The --no-spec flag skips test file generation to keep things focused.

The first command also adds CategoriesModule to your AppModule imports array automatically.

  • Verify the CLI registered the module by opening src/app.module.ts. You should see CategoriesModule in the imports array alongside PrismaModule and BookmarksModule.
  • Create the DTO file. In src/categories/, create a folder called dto, then create create-category.dto.ts inside it with this code:
import { IsNotEmpty, IsString } from 'class-validator';

export class CreateCategoryDto {
  @IsNotEmpty()
  @IsString()
  name: string;
}

What does this DTO do?

The CreateCategoryDto ensures every category has a non-empty string name. The ValidationPipe you set up earlier will reject any POST to /categories that is missing a name or sends a non-string value.

  • Save create-category.dto.ts.
  • Open src/categories/categories.service.ts and replace its contents with the following code that implements create and findAll:
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';

@Injectable()
export class CategoriesService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreateCategoryDto) {
    return this.prisma.category.create({
      data: dto,
    });
  }

  async findAll() {
    return this.prisma.category.findMany({
      include: { bookmarks: true },
    });
  }
}

What does this service do?

  • create() inserts a new category row using the validated DTO.
  • findAll() returns every category and includes its associated bookmarks. The include: { bookmarks: true } tells Prisma to join the related Bookmark records.
  • Save categories.service.ts.
  • Now open src/categories/categories.controller.ts and replace its contents with the following code for the POST and GET routes:
import {
  Controller,
  Get,
  Post,
  Delete,
  Body,
  Param,
  ParseIntPipe,
} from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto } from './dto/create-category.dto';

@Controller('categories')
export class CategoriesController {
  constructor(private readonly categoriesService: CategoriesService) {}

  @Post()
  create(@Body() dto: CreateCategoryDto) {
    return this.categoriesService.create(dto);
  }

  @Get()
  findAll() {
    return this.categoriesService.findAll();
  }
}
  • Save categories.controller.ts. Check your dev server terminal for a clean compilation.
  • Test creating a category by running:
curl -X POST http://localhost:3000/categories -H "Content-Type: application/json" -d '{"name":"Development"}'

You should see a JSON response with your new category, including an id, name, and timestamps.

Getting a 500 error or Prisma connection issue?

Make sure you ran npx prisma generate after the migration. The dev server needs the regenerated client to know about the Category model.

Also confirm your PostgreSQL server is running and the DATABASE_URL in .env is correct.

help me debug a Prisma connection error when creating a category

  • Now add the findOne and remove methods. In categories.service.ts, add these methods below findAll(), before the closing brace of the class:
  async findOne(id: number) {
    const category = await this.prisma.category.findUnique({
      where: { id },
      include: { bookmarks: true },
    });
    if (!category) {
      throw new NotFoundException(`Category with ID ${id} not found`);
    }
    return category;
  }

  async remove(id: number) {
    await this.findOne(id);
    return this.prisma.category.delete({
      where: { id },
    });
  }

What do these methods do?

  • findOne() fetches a single category by ID and throws a NotFoundException (HTTP 404) if it does not exist.
  • remove() calls findOne() first to ensure the category exists before deleting it. This gives a clear 404 instead of a database error.
  • Save categories.service.ts.
  • Add the matching routes in categories.controller.ts. Add these methods below findAll(), before the closing brace of the class:
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.categoriesService.findOne(id);
  }

  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.categoriesService.remove(id);
  }
  • Save categories.controller.ts. Check your dev server terminal for a clean compilation.
  • Test fetching a single category by running:
curl http://localhost:3000/categories/1

You should see the "Development" category you created earlier, along with an empty bookmarks array.

✔️ Awesome, I've got everything!

Great. Double check you have saved both categories.service.ts and categories.controller.ts.

ⓧ I'd like to double check the full code

Your categories.service.ts and categories.controller.ts are each over 30 lines when complete. Scroll up and confirm these chunks are present in each file, in order:

  • categories.service.ts: imports and class with create() and findAll(), then findOne() and remove() before the closing brace.
  • categories.controller.ts: imports and class with @Post() and @Get() routes, then @Get(':id') and @Delete(':id') routes before the closing brace.
Test category filtering on bookmarks

Your bookmarks controller already accepts a categoryId query parameter and your service filters by it. Now that the Category table exists, this filtering actually works.

  • Create a bookmark assigned to your "Development" category by running:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"NestJS Docs","url":"https://docs.nestjs.com","categoryId":1}'

You should see the bookmark returned with a category object containing the "Development" category data.

  • Create a second bookmark without a category to have comparison data:
curl -X POST http://localhost:3000/bookmarks -H "Content-Type: application/json" -d '{"title":"Random Link","url":"https://example.com"}'

Before you run the next command, what do you expect to see when you filter bookmarks by categoryId=1? Will it return one bookmark or two?

  • Filter bookmarks by category by running:
curl http://localhost:3000/bookmarks?categoryId=1

You see only the "NestJS Docs" bookmark returned. The "Random Link" bookmark is excluded because it has no category assigned. The where: { categoryId } clause in your service filters the results down to only bookmarks in that category.

Seeing all bookmarks instead of filtered results?

Open src/bookmarks/bookmarks.controller.ts and confirm the findAll method has @Query('categoryId') categoryId?: string in its parameter list. The controller must parse the query string and pass it to the service.

Also confirm src/bookmarks/bookmarks.service.ts uses const where = categoryId ? { categoryId } : {} to build the filter conditionally.

help me debug why bookmarks are not filtering by categoryId

Categories and relational filtering are working. Next up, you will tackle a secret mission to add pagination and search to your bookmarks endpoint.

Secret mission

Add Pagination and Search

Your API works great for a handful of bookmarks. But what happens when you have hundreds? A real-world API needs pagination to return manageable chunks and search to help users find what they need. This mission pushes you to combine multiple optional query parameters into a single dynamic Prisma query.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with PostgreSQL, so there are no ongoing cloud costs.

Resources you used:

  • Local PostgreSQL database bookmark_api.
  • The bookmark-api project folder on your Desktop.
  • NestJS CLI installed globally via npm.

Keep everything running

No action needed. Choose this if you are still actively building or plan to add authentication in a follow-on project.

  • Your PostgreSQL database persists locally at no cost.
  • The NestJS CLI remains available globally for future projects.
  • Your project folder stays on your Desktop ready to pick back up.

Pause - I'll come back to this later

Stop the running dev server to free up memory. Your files and database stay intact for when you return.

  • Press Ctrl+C in the terminal where the dev server is running to stop it.
  • Your database data remains stored locally in PostgreSQL.
  • When you are ready to resume, open the bookmark-api folder and run npm run start:dev again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to clean up everything.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Reset and drop the PostgreSQL database by running:
npx prisma migrate reset

Alternatively, drop the database entirely by running:

dropdb bookmark_api
  • Delete the project folder on macOS/Linux by running:
rm -rf ~/Desktop/bookmark-api

On Windows, run:

rmdir /s /q %USERPROFILE%\Desktop\bookmark-api
  • Uninstall the NestJS CLI globally by running:
npm uninstall -g @nestjs/cli

Nice Work!

Nice Work!

Nice work! You just built a fully functional Bookmark Manager REST API from an empty terminal to a production-style backend serving CRUD operations, input validation, and relational data.

You've learned how to:

  • Build a NestJS REST API from scratch using the CLI and the module/controller/service architecture, with full CRUD endpoints for bookmarks and categories.
  • Configure Prisma 7 with the new driver adapter pattern, model a one-to-many relation between categories and bookmarks, and run database migrations against PostgreSQL.
  • Validate incoming requests with DTOs, class-validator decorators, and the global ValidationPipe so malformed input is rejected with clear error messages before it reaches the database.
  • Secret Mission: Added pagination and full-text search with dynamic query building to handle large bookmark collections.

Ready to quiz yourself?