DEV Community

Vedant Kumar
Vedant Kumar

Posted on

Building a Temporary Message Sharing API with NestJS, PostgreSQL, Prisma & Redis

I recently built a temporary message-sharing API from scratch using NestJS, PostgreSQL, Prisma and Redis.

The idea is simple:

Create a message, generate a short code, share it, and let the message automatically expire.

But while building it, I wanted to go beyond a basic CRUD API and understand how things like validation, password protection, visit limits, atomic database updates, caching, TTLs, cleanup jobs, rate limiting and API documentation actually fit together.

This post walks through the architecture and the problems each piece solves.


๐Ÿš€ What did I build?

The API allows users to create temporary messages with configurable rules:

  • Maximum message length: 100 characters
  • Expiration: 1 hour, 1 day, or 7 days
  • Maximum number of visits
  • Optional password protection
  • Optional one-time access
  • Automatic expiration
  • Redis caching
  • Rate limiting
  • Swagger API documentation
  • No authentication or user accounts

The basic flow looks like this:

Client
  โ”‚
  โ–ผ
NestJS Controller
  โ”‚
  โ–ผ
DTO Validation
  โ”‚
  โ–ผ
LinksService
  โ”‚
  โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Redis
  โ”‚
  โ–ผ
Prisma
  โ”‚
  โ–ผ
PostgreSQL
Enter fullscreen mode Exit fullscreen mode

๐Ÿ› ๏ธ Tech Stack

The project uses:

Technology Purpose
NestJS Backend framework
TypeScript Programming language
PostgreSQL Persistent database
Prisma ORM
Redis Caching
Memurai Redis-compatible server on Windows
bcrypt Password hashing
Swagger API documentation
class-validator DTO validation
@nestjs/schedule Cleanup cron jobs
@nestjs/throttler Rate limiting

๐Ÿ“ Project Structure

The project is organized into modules instead of putting everything into one large file.

src/
โ”œโ”€โ”€ links/
โ”‚   โ”œโ”€โ”€ dto/
โ”‚   โ”‚   โ”œโ”€โ”€ create-link.dto.ts
โ”‚   โ”‚   โ””โ”€โ”€ access-link.dto.ts
โ”‚   โ”œโ”€โ”€ links.controller.ts
โ”‚   โ”œโ”€โ”€ links.service.ts
โ”‚   โ””โ”€โ”€ links.module.ts
โ”‚
โ”œโ”€โ”€ prisma/
โ”‚   โ”œโ”€โ”€ prisma.service.ts
โ”‚   โ””โ”€โ”€ prisma.module.ts
โ”‚
โ”œโ”€โ”€ redis/
โ”‚   โ”œโ”€โ”€ redis.service.ts
โ”‚   โ””โ”€โ”€ redis.module.ts
โ”‚
โ”œโ”€โ”€ app.module.ts
โ””โ”€โ”€ main.ts
Enter fullscreen mode Exit fullscreen mode

I kept the project intentionally small so that each layer had a clear responsibility.


1. Creating a Temporary Message

The first endpoint is:

POST /links
Enter fullscreen mode Exit fullscreen mode

A request looks like:

{
  "message": "Hello, this message will expire!",
  "expiresIn": 3600,
  "maxVisits": 5,
  "password": "secret123",
  "oneTime": false
}
Enter fullscreen mode Exit fullscreen mode

The server generates:

shortCode
expiresAt
Enter fullscreen mode Exit fullscreen mode

The response is:

{
  "code": "6639befd",
  "expiresAt": "2026-09-19T15:30:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

The client can then share:

/link/6639befd
Enter fullscreen mode Exit fullscreen mode

2. DTO Validation

One of the first things I learned was that validation should happen at the API boundary.

Instead of manually checking every property inside the service, NestJS can validate the incoming request using DTOs.

For example:

export class CreateLinkDto {
  @IsString()
  @MinLength(1)
  @MaxLength(100)
  message: string;

  @IsInt()
  @Min(60)
  @Max(604800)
  expiresIn: number;

  @IsOptional()
  @IsInt()
  @Min(1)
  maxVisits?: number;

  @IsOptional()
  @IsString()
  @MinLength(4)
  password?: string;

  @IsOptional()
  @IsBoolean()
  oneTime?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

This means the API rejects invalid data before it reaches the business logic.

For example:

{
  "message": "",
  "expiresIn": 10
}
Enter fullscreen mode Exit fullscreen mode

will fail validation because:

message.length >= 1
expiresIn >= 60
Enter fullscreen mode Exit fullscreen mode

The global validation pipe is configured like this:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    transform: true,
  }),
);
Enter fullscreen mode Exit fullscreen mode

The whitelist option also prevents unexpected properties from being accepted.


3. Calculating Expiration

The client sends the duration in seconds.

For example:

3600 seconds = 1 hour
Enter fullscreen mode Exit fullscreen mode

The server converts it into an actual expiration timestamp:

const expiresAt = new Date(
  Date.now() + createLinkDto.expiresIn * 1000,
);
Enter fullscreen mode Exit fullscreen mode

So instead of storing:

expiresIn = 3600
Enter fullscreen mode Exit fullscreen mode

the database stores:

expiresAt = 2026-09-19T15:30:00Z
Enter fullscreen mode Exit fullscreen mode

This makes expiration checks straightforward:

if (link.expiresAt <= new Date()) {
  throw new GoneException('Link has expired');
}
Enter fullscreen mode Exit fullscreen mode

4. PostgreSQL Database

The persistent data is stored in PostgreSQL.

The core model looks like this:

model link {
  code         String   @unique
  createdAt    DateTime @default(now()) @db.Timestamptz(6)
  expiresAt    DateTime @db.Timestamptz(6)
  id           String   @id @db.Uuid
  maxVisits    Int?
  message      String   @db.VarChar(100)
  oneTime      Boolean  @default(false)
  passwordHash String?
  updatedAt    DateTime @db.Timestamptz(6)
  used         Boolean  @default(false)
  visitCount   Int      @default(0)

  @@index([expiresAt])
}
Enter fullscreen mode Exit fullscreen mode

There are a few important fields here.

expiresAt

Determines when the message expires.

maxVisits

Optional maximum number of successful accesses.

visitCount

Tracks successful accesses.

oneTime

Determines whether the message can only be accessed once.

used

Tracks whether a one-time message has already been consumed.

passwordHash

Stores the hashed password rather than the original password.


5. Password Protection

I didn't want passwords to be stored as plain text.

Instead, the password is hashed using bcrypt.

const passwordHash = createLinkDto.password
  ? await bcrypt.hash(createLinkDto.password, 10)
  : null;
Enter fullscreen mode Exit fullscreen mode

The database therefore contains something like:

$2b$10$.....................................................
Enter fullscreen mode Exit fullscreen mode

instead of:

secret123
Enter fullscreen mode Exit fullscreen mode

When someone accesses a protected message, the supplied password is compared against the hash:

const isPasswordValid = await bcrypt.compare(
  password,
  link.passwordHash,
);
Enter fullscreen mode Exit fullscreen mode

If the password is incorrect:

throw new UnauthorizedException(
  'Invalid password',
);
Enter fullscreen mode Exit fullscreen mode

6. Public vs Protected Messages

There are two access flows.

Public message

GET /links/:shortCode
Enter fullscreen mode Exit fullscreen mode

Example:

GET /links/6639befd
Enter fullscreen mode Exit fullscreen mode

If the message isn't password protected, the API directly returns:

{
  "message": "Hello!"
}
Enter fullscreen mode Exit fullscreen mode

Protected message

The frontend first calls:

GET /links/6639befd
Enter fullscreen mode Exit fullscreen mode

The API responds:

401 Unauthorized
Enter fullscreen mode Exit fullscreen mode

The frontend can then display a password input.

After the user enters the password:

POST /links/6639befd/access
Enter fullscreen mode Exit fullscreen mode

with:

{
  "password": "secret123"
}
Enter fullscreen mode Exit fullscreen mode

The shared URL remains:

/link/6639befd
Enter fullscreen mode Exit fullscreen mode

The /access endpoint is only an API endpoint used by the frontend.


7. Maximum Visit Limit

One interesting part of the project was implementing maximum visits correctly.

Suppose a message has:

{
  "maxVisits": 2
}
Enter fullscreen mode Exit fullscreen mode

We want:

Request 1 โ†’ 200
Request 2 โ†’ 200
Request 3 โ†’ 410
Enter fullscreen mode Exit fullscreen mode

A naive implementation would be:

if (link.visitCount >= link.maxVisits) {
  throw new GoneException();
}

await prisma.link.update({
  data: {
    visitCount: {
      increment: 1,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

But this has a concurrency problem.

Imagine two requests arrive at almost exactly the same time:

Request A โ†’ visitCount = 1
Request B โ†’ visitCount = 1
Enter fullscreen mode Exit fullscreen mode

Both could pass the check before either increments the counter.

That could allow more accesses than intended.


8. Atomic Access Claim

Instead, I used a conditional database update.

const result = await this.prisma.link.updateMany({
  where: {
    id: link.id,
    AND: [
      {
        OR: [
          {
            maxVisits: null,
          },
          {
            maxVisits: {
              gt: link.visitCount,
            },
          },
        ],
      },
    ],
  },
  data: {
    visitCount: {
      increment: 1,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Then:

if (result.count === 0) {
  throw new GoneException(
    'Link has reached its maximum visits',
  );
}
Enter fullscreen mode Exit fullscreen mode

The important idea is:

Don't separate "check" and "update" when the correctness of the operation depends on both happening together.

The database becomes responsible for atomically claiming the access.


9. One-Time Messages

The same idea is used for one-time messages.

For a one-time message:

{
  "oneTime": true
}
Enter fullscreen mode Exit fullscreen mode

the first successful access should work:

First request  โ†’ 200
Second request โ†’ 410
Enter fullscreen mode Exit fullscreen mode

The database update includes:

used: false
Enter fullscreen mode Exit fullscreen mode

as part of the condition.

Then the successful update sets:

used: true
Enter fullscreen mode Exit fullscreen mode

The important part is that checking and consuming the message happen as one database operation.


10. Adding Redis

After the core PostgreSQL implementation was working, I added Redis.

The purpose wasn't simply:

"I need Redis because real projects use Redis."

Instead, I wanted to solve a specific problem:

Repeatedly reading frequently accessed temporary messages from PostgreSQL isn't necessary when the message itself can be cached.

The flow becomes:

Client
  โ”‚
  โ–ผ
NestJS
  โ”‚
  โ–ผ
Redis
  โ”‚
  โ”œโ”€โ”€ HIT โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Return message
  โ”‚
  โ””โ”€โ”€ MISS
        โ”‚
        โ–ผ
     PostgreSQL
Enter fullscreen mode Exit fullscreen mode

11. Redis TTL

The nice part about temporary data is that Redis already supports expiration.

When creating a cache entry:

await this.redis.setWithExpiry(
  `link:${link.code}`,
  link.message,
  createLinkDto.expiresIn,
);
Enter fullscreen mode Exit fullscreen mode

The Redis operation uses:

EX = expiration time in seconds
Enter fullscreen mode Exit fullscreen mode

So if the message expires in one hour:

TTL = 3600
Enter fullscreen mode Exit fullscreen mode

Redis automatically removes the key when the TTL reaches zero.

I tested it using:

memurai-cli
Enter fullscreen mode Exit fullscreen mode

and:

KEYS link:*
Enter fullscreen mode Exit fullscreen mode

which returned:

1) "link:6639befd"
Enter fullscreen mode Exit fullscreen mode

Then:

GET link:6639befd
Enter fullscreen mode Exit fullscreen mode

returned:

"Redis test message"
Enter fullscreen mode Exit fullscreen mode

And:

TTL link:6639befd
Enter fullscreen mode Exit fullscreen mode

returned something like:

(integer) 3531
Enter fullscreen mode Exit fullscreen mode

12. Why Not Cache Everything?

This was an important design decision.

Messages with these features cannot simply be treated as immutable cached values:

maxVisits
oneTime
password protection
Enter fullscreen mode Exit fullscreen mode

because access requires additional state and validation.

So I only cache messages that are safe to serve directly:

private isCacheable(link: {
  passwordHash: string | null;
  maxVisits: number | null;
  oneTime: boolean;
}) {
  return (
    !link.passwordHash &&
    link.maxVisits === null &&
    !link.oneTime
  );
}
Enter fullscreen mode Exit fullscreen mode

In other words:

Simple public message
        โ†“
      Redis
        โ†“
    fast access
Enter fullscreen mode Exit fullscreen mode

while:

Password protected
       OR
Maximum visits
       OR
One-time
        โ†“
PostgreSQL
        โ†“
validation + atomic access
Enter fullscreen mode Exit fullscreen mode

This keeps Redis from bypassing access-control logic.


13. Automatic Cleanup

Redis handles its own TTL, but PostgreSQL still contains expired records.

So I added a scheduled cleanup job using:

@nestjs/schedule
Enter fullscreen mode Exit fullscreen mode

The job runs every hour:

@Cron('0 * * * *')
async cleanupExpiredLinks() {
  const result = await this.prisma.link.deleteMany({
    where: {
      expiresAt: {
        lt: new Date(),
      },
    },
  });

  if (result.count > 0) {
    this.logger.log(
      `Deleted ${result.count} expired link(s)`,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This keeps the PostgreSQL table from growing indefinitely.

The architecture is therefore:

PostgreSQL
    โ”‚
    โ””โ”€โ”€ Cleanup job
          โ”‚
          โ””โ”€โ”€ Delete expired records

Redis
    โ”‚
    โ””โ”€โ”€ TTL
          โ”‚
          โ””โ”€โ”€ Automatically remove expired cache
Enter fullscreen mode Exit fullscreen mode

14. Rate Limiting

Since this is a public API, unrestricted requests could become a problem.

I added NestJS throttling:

ThrottlerModule.forRoot([
  {
    ttl: 60_000,
    limit: 60,
  },
])
Enter fullscreen mode Exit fullscreen mode

This allows roughly:

60 requests
per 60 seconds
Enter fullscreen mode Exit fullscreen mode

per client according to the throttler's request-tracking behavior.

The guard is registered globally:

providers: [
  {
    provide: APP_GUARD,
    useClass: ThrottlerGuard,
  },
],
Enter fullscreen mode Exit fullscreen mode

This is a simple first layer of protection against accidental or abusive request bursts.


15. Swagger Documentation

I also added Swagger because an API shouldn't require someone to read the source code to understand how to use it.

Swagger is available at:

/api/docs
Enter fullscreen mode Exit fullscreen mode

The application is configured with:

const config = new DocumentBuilder()
  .setTitle('Link Expiry API')
  .setDescription('Temporary message sharing API')
  .setVersion('1.0')
  .build();

const document = SwaggerModule.createDocument(
  app,
  config,
);

SwaggerModule.setup(
  'api/docs',
  app,
  document,
);
Enter fullscreen mode Exit fullscreen mode

DTO properties are also documented:

@ApiProperty({
  description: 'Temporary message to store',
  example: 'Hello, this message will expire!',
  minLength: 1,
  maxLength: 100,
})
Enter fullscreen mode Exit fullscreen mode

This makes the API much easier to explore and test.


๐Ÿ”Œ API Endpoints

The API currently exposes three main endpoints.

Create a message

POST /links
Enter fullscreen mode Exit fullscreen mode

Example:

{
  "message": "Hello from my API!",
  "expiresIn": 3600
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "code": "6639befd",
  "expiresAt": "2026-09-19T15:30:00.000Z"
}
Enter fullscreen mode Exit fullscreen mode

Access a message

GET /links/:shortCode
Enter fullscreen mode Exit fullscreen mode

Example:

GET /links/6639befd
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "message": "Hello from my API!"
}
Enter fullscreen mode Exit fullscreen mode

Access a protected message

POST /links/:shortCode/access
Enter fullscreen mode Exit fullscreen mode

Request:

{
  "password": "secret123"
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "message": "This is a protected message."
}
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ HTTP Status Codes

I also wanted the API behavior to be explicit.

Status Meaning
200 Message successfully retrieved
201 Message successfully created
400 Invalid request
401 Password required or incorrect
404 Message doesn't exist
410 Message expired or can no longer be accessed

Using 410 Gone for expired messages felt appropriate because the resource existed but is no longer available.


๐Ÿงช Manual Testing

I decided to manually test the API rather than immediately building a large automated test suite.

Some of the scenarios tested were:

โœ“ Create public message
โœ“ Create password-protected message
โœ“ Correct password
โœ“ Incorrect password
โœ“ Expired message
โœ“ Maximum visit limit
โœ“ One-time message
โœ“ Redis cache hit
โœ“ Redis TTL
โœ“ PostgreSQL fallback
โœ“ Rate limiting
โœ“ Swagger documentation
โœ“ Expired-record cleanup
Enter fullscreen mode Exit fullscreen mode

For example:

Invoke-RestMethod `
  http://localhost:3000/links/6639befd
Enter fullscreen mode Exit fullscreen mode

returned:

message
-------
Redis test message
Enter fullscreen mode Exit fullscreen mode

showing that the cached message could be served through the API.


๐Ÿง  What I Learned

The biggest value of this project wasn't the final API.

It was understanding why each component exists.

NestJS

I learned how controllers, services, modules, DTOs and dependency injection fit together.

DTO Validation

I learned that validating input at the API boundary makes business logic much cleaner.

PostgreSQL

I learned how persistent application state differs from temporary cached state.

Prisma

I learned how an ORM interacts with the database and how conditional updates can help with concurrency.

bcrypt

I learned why passwords should never be stored directly.

Redis

I learned that caching isn't simply:

"Put everything in Redis."
Enter fullscreen mode Exit fullscreen mode

Instead, you need to decide:

What can safely be cached?
What state must remain authoritative?
When should the cache expire?
Enter fullscreen mode Exit fullscreen mode

Atomic Operations

This was probably one of the most useful concepts from the project.

Instead of:

Check
 โ†“
Update
Enter fullscreen mode Exit fullscreen mode

sometimes you need:

Conditional Update
       โ†“
   Success/Failure
Enter fullscreen mode Exit fullscreen mode

so concurrent requests can't bypass your business rule.

Cron Jobs

I also learned that expiration in an application doesn't automatically mean the database cleans itself up.


๐Ÿ—๏ธ Current Architecture

The final architecture looks roughly like this:

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚    Client     โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚ NestJS API    โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚ Validation    โ”‚
                    โ”‚ + Throttling  โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚ LinksService  โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                โ”‚                       โ”‚
                โ–ผ                       โ–ผ
        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
        โ”‚    Redis     โ”‚       โ”‚   PostgreSQL   โ”‚
        โ”‚              โ”‚       โ”‚                โ”‚
        โ”‚ Cached       โ”‚       โ”‚ Source of      โ”‚
        โ”‚ messages     โ”‚       โ”‚ truth          โ”‚
        โ”‚              โ”‚       โ”‚                โ”‚
        โ”‚ TTL          โ”‚       โ”‚ Visit limits   โ”‚
        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜       โ”‚ One-time state โ”‚
                               โ”‚ Password hash  โ”‚
                               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                        โ”‚
                                        โ–ผ
                                โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                                โ”‚ Cron Cleanup โ”‚
                                โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ฆ Running the Project

Install dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Start PostgreSQL and Redis/Memurai.

Then configure:

DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/link_expiry"
REDIS_URL="redis://localhost:6379"
Enter fullscreen mode Exit fullscreen mode

Generate Prisma Client:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Validate the Prisma schema:

npx prisma validate
Enter fullscreen mode Exit fullscreen mode

Start NestJS:

npm run start:dev
Enter fullscreen mode Exit fullscreen mode

The API will be available at:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Swagger:

http://localhost:3000/api/docs
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”ฎ What Could Come Next?

There are several directions this project could evolve in.

For example:

Authentication
Analytics
Custom expiration
Frontend UI
Admin dashboard
Distributed rate limiting
Redis-based counters
Background workers
Docker deployment
Observability
Metrics
Horizontal scaling
Enter fullscreen mode Exit fullscreen mode

But I intentionally didn't add everything at once.

The goal was to understand the fundamentals first and then introduce infrastructure only when there was a reason for it.


๐Ÿ’ญ Final Thoughts

What started as a simple:

"Create a message โ†’ give me a link โ†’ expire it"
Enter fullscreen mode Exit fullscreen mode

turned into a surprisingly good backend learning project.

The interesting part wasn't creating the endpoint.

It was figuring out questions like:

What happens when two users access the message simultaneously?

Where should expiration be enforced?

Should every message be cached?

What happens to expired database records?

How should passwords be stored?

How do we prevent unlimited requests?

How do we make the API easy for others to understand?
Enter fullscreen mode Exit fullscreen mode

Those questions pushed the project beyond a basic CRUD API and helped me understand several backend concepts that I had previously only seen in theory.


๐Ÿš€ Tech Stack

NestJS
TypeScript
PostgreSQL
Prisma
Redis
Memurai
bcrypt
Swagger
class-validator
@nestjs/schedule
@nestjs/throttler
Enter fullscreen mode Exit fullscreen mode

If you're also learning backend development, I'd highly recommend building something small and then adding complexity only when you can explain why you need it.

That's what made this project much more useful for me than simply following a tutorial.


๐Ÿ“Œ Source Code

The complete project is available on GitHub:

GitHub link

If you find the project useful or have suggestions for improving the architecture, I'd love to hear your feedback.


๐Ÿ‘จโ€๐Ÿ’ป About the Project

Built as a hands-on learning project to understand:

NestJS
     โ†“
API Design
     โ†“
Validation
     โ†“
PostgreSQL
     โ†“
Prisma
     โ†“
Concurrency
     โ†“
Redis
     โ†“
Caching + TTL
     โ†“
Background Cleanup
     โ†“
Rate Limiting
Enter fullscreen mode Exit fullscreen mode

Tags: #nestjs #typescript #postgresql #prisma #redis #backend #webdevelopment #javascript

Top comments (0)