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, or7 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
๐ ๏ธ 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
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
A request looks like:
{
"message": "Hello, this message will expire!",
"expiresIn": 3600,
"maxVisits": 5,
"password": "secret123",
"oneTime": false
}
The server generates:
shortCode
expiresAt
The response is:
{
"code": "6639befd",
"expiresAt": "2026-09-19T15:30:00.000Z"
}
The client can then share:
/link/6639befd
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;
}
This means the API rejects invalid data before it reaches the business logic.
For example:
{
"message": "",
"expiresIn": 10
}
will fail validation because:
message.length >= 1
expiresIn >= 60
The global validation pipe is configured like this:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
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
The server converts it into an actual expiration timestamp:
const expiresAt = new Date(
Date.now() + createLinkDto.expiresIn * 1000,
);
So instead of storing:
expiresIn = 3600
the database stores:
expiresAt = 2026-09-19T15:30:00Z
This makes expiration checks straightforward:
if (link.expiresAt <= new Date()) {
throw new GoneException('Link has expired');
}
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])
}
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;
The database therefore contains something like:
$2b$10$.....................................................
instead of:
secret123
When someone accesses a protected message, the supplied password is compared against the hash:
const isPasswordValid = await bcrypt.compare(
password,
link.passwordHash,
);
If the password is incorrect:
throw new UnauthorizedException(
'Invalid password',
);
6. Public vs Protected Messages
There are two access flows.
Public message
GET /links/:shortCode
Example:
GET /links/6639befd
If the message isn't password protected, the API directly returns:
{
"message": "Hello!"
}
Protected message
The frontend first calls:
GET /links/6639befd
The API responds:
401 Unauthorized
The frontend can then display a password input.
After the user enters the password:
POST /links/6639befd/access
with:
{
"password": "secret123"
}
The shared URL remains:
/link/6639befd
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
}
We want:
Request 1 โ 200
Request 2 โ 200
Request 3 โ 410
A naive implementation would be:
if (link.visitCount >= link.maxVisits) {
throw new GoneException();
}
await prisma.link.update({
data: {
visitCount: {
increment: 1,
},
},
});
But this has a concurrency problem.
Imagine two requests arrive at almost exactly the same time:
Request A โ visitCount = 1
Request B โ visitCount = 1
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,
},
},
});
Then:
if (result.count === 0) {
throw new GoneException(
'Link has reached its maximum visits',
);
}
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
}
the first successful access should work:
First request โ 200
Second request โ 410
The database update includes:
used: false
as part of the condition.
Then the successful update sets:
used: true
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
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,
);
The Redis operation uses:
EX = expiration time in seconds
So if the message expires in one hour:
TTL = 3600
Redis automatically removes the key when the TTL reaches zero.
I tested it using:
memurai-cli
and:
KEYS link:*
which returned:
1) "link:6639befd"
Then:
GET link:6639befd
returned:
"Redis test message"
And:
TTL link:6639befd
returned something like:
(integer) 3531
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
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
);
}
In other words:
Simple public message
โ
Redis
โ
fast access
while:
Password protected
OR
Maximum visits
OR
One-time
โ
PostgreSQL
โ
validation + atomic access
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
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)`,
);
}
}
This keeps the PostgreSQL table from growing indefinitely.
The architecture is therefore:
PostgreSQL
โ
โโโ Cleanup job
โ
โโโ Delete expired records
Redis
โ
โโโ TTL
โ
โโโ Automatically remove expired cache
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,
},
])
This allows roughly:
60 requests
per 60 seconds
per client according to the throttler's request-tracking behavior.
The guard is registered globally:
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
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
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,
);
DTO properties are also documented:
@ApiProperty({
description: 'Temporary message to store',
example: 'Hello, this message will expire!',
minLength: 1,
maxLength: 100,
})
This makes the API much easier to explore and test.
๐ API Endpoints
The API currently exposes three main endpoints.
Create a message
POST /links
Example:
{
"message": "Hello from my API!",
"expiresIn": 3600
}
Response:
{
"code": "6639befd",
"expiresAt": "2026-09-19T15:30:00.000Z"
}
Access a message
GET /links/:shortCode
Example:
GET /links/6639befd
Response:
{
"message": "Hello from my API!"
}
Access a protected message
POST /links/:shortCode/access
Request:
{
"password": "secret123"
}
Response:
{
"message": "This is a protected message."
}
โ ๏ธ 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
For example:
Invoke-RestMethod `
http://localhost:3000/links/6639befd
returned:
message
-------
Redis test message
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."
Instead, you need to decide:
What can safely be cached?
What state must remain authoritative?
When should the cache expire?
Atomic Operations
This was probably one of the most useful concepts from the project.
Instead of:
Check
โ
Update
sometimes you need:
Conditional Update
โ
Success/Failure
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 โ
โโโโโโโโโโโโโโโโ
๐ฆ Running the Project
Install dependencies:
npm install
Start PostgreSQL and Redis/Memurai.
Then configure:
DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/link_expiry"
REDIS_URL="redis://localhost:6379"
Generate Prisma Client:
npx prisma generate
Validate the Prisma schema:
npx prisma validate
Start NestJS:
npm run start:dev
The API will be available at:
http://localhost:3000
Swagger:
http://localhost:3000/api/docs
๐ฎ 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
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"
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?
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
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:
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
Tags: #nestjs #typescript #postgresql #prisma #redis #backend #webdevelopment #javascript
Top comments (0)