A full-stack editorial blog platform where authenticated writers publish articles, manage drafts, and readers discover, like, and comment on stories — powered by a NestJS GraphQL API and a Next.js App Router frontend.
- Features
- Screenshots
- Tech Stack
- Architecture Overview
- Project Structure
- Database Schema & Relationships
- Sequence Diagrams
- Environment Variables
- Getting Started
- Available Scripts
- Application Routes / API Endpoints
- Business Logic
- Access Control
- Third-Party Integrations
- Deployment
- Key Design Decisions
- Seed Data & Default Credentials
- Contributing
- License
- Email/password authentication — Users register and sign in through the API; passwords are hashed with Argon2 before they ever touch the database.
- Google OAuth2 sign-in (SSO) — One-click sign-in via Google, with automatic account creation (find-or-create by email).
- Dual-token session model — The API issues a short-lived JWT access token; the frontend wraps it in a 7-day signed
sessioncookie (HS256 viajose) that carries the user profile for every request. - Protected dashboard routes — Next.js
middleware.tsintercepts every/user/*request and redirects anonymous visitors to the sign-in page. - Post authoring with drafts — Writers create, edit, and delete their own posts; the
publishedflag lets them save a draft and publish later. - Tag support — Posts connect to tags with an connect-or-create strategy: tags are created on the fly if they do not exist yet, and re-synced on every update.
- Post likes — Authenticated users can like/unlike a post exactly once (enforced with a unique
(userId, postId)constraint). The frontend applies optimistic updates via TanStack React Query. - Commenting — Signed-in users leave comments on posts; the list is paginated (12 per page), ordered newest-first, and shows the author's avatar and name.
- Thumbnail uploads — Post thumbnails are uploaded from the Next.js server action directly to Supabase Storage (public bucket
thumbnail-bucket-for-new-post) and stored as public URLs. - Safe HTML rendering — Post bodies are sanitized client-side with isomorphic-dompurify before being injected via
dangerouslySetInnerHTML. - Server-side form validation — Every form (sign-up, sign-in, post, comment) is validated with zod server actions; the API re-validates inputs with
class-validator. - Pagination throughout — Home feed and the "Your posts" dashboard paginate with
skip/take(default page size 12). - Responsive editorial UI — Tailwind CSS v4 + shadcn/ui (Base UI) component library, HugeIcons, dark-mode ready, with mobile drawer navigation.
- Realistic seed data — A deterministic-looking seed script generates 10 users, 8 tags, 40 topical articles with unique titles/slugs, and 20 comments per post.
| Page | Screenshot |
|---|---|
| Home / article feed | ![]() |
| Article detail with like & comments | ![]() |
| Sign in / Sign up | ![]() |
| Create / edit post | ![]() |
| Your posts dashboard | ![]() |
| Layer | Technology | Version (declared) |
|---|---|---|
| Monorepo tooling | Turborepo | ^2.10.12 |
| Package manager | pnpm | 12.3.3 (package.json packageManager) |
| Language | TypeScript | API ^6.0.2 · Front ^5 |
| API — framework | NestJS (@nestjs/core, @nestjs/common) |
^12.0.1 |
| API — GraphQL server | Apollo Server (@apollo/server) |
^5.5.1 |
| API — GraphQL integration | @nestjs/graphql / @nestjs/apollo |
^14.0.0 |
| API — GraphQL core | graphql |
^17.0.2 |
| API — ORM | @prisma/client + prisma |
^7.10.0 |
| API — PostgreSQL driver adapter | @prisma/adapter-pg |
^7.10.0 |
| API — Auth (JWT) | @nestjs/jwt, passport-jwt |
^12.0.1 / ^4.0.1 |
| API — Auth (Google) | passport-google-oauth20, @nestjs/passport |
^2.0.0 / ^12.0.0 |
| API — Password hashing | argon2 |
^0.45.1 |
| API — Validation | class-validator, class-transformer |
^0.15.1 / ^0.5.1 |
| API — Observability | @nestjs/observe |
^0.1.8 |
| API — Tests | Vitest (vitest) |
^4.1.2 |
| API — Linting | oxlint | ^1.58.0 |
| Front — framework | Next.js | 16.3.4 |
| Front — UI runtime | React / react-dom | 19.2.8 |
| Front — Styling | Tailwind CSS + @tailwindcss/postcss |
^4 |
| Front — Component library | shadcn (shadcn CLI), @base-ui/react |
^4.21.0 / ^1.8.0 |
| Front — Icons | HugeIcons | ^1.1.10 |
| Front — Data fetching | TanStack React Query | ^5.102.8 |
| Front — Storage client | @supabase/supabase-js |
^2.116.0 |
| Front — Sessions | jose |
^6.2.12 |
| Front — Validation | zod |
^4.5.4 |
| Front — Sanitization | isomorphic-dompurify |
^2.22.0 |
| Front — Toasts | sonner |
^2.0.8 |
| Front — Theming | next-themes |
^0.4.6 |
All versions above are the ranges pinned in each app's
package.json. The exact resolved versions live inpnpm-lock.yaml.
graph TB
subgraph Browser["Browser / Client"]
UI["Next.js App (Server & Client Components)"]
SW["Server Actions (signUp, signIn, saveNewPost, saveComment, likePost, ...)"]
RQ["TanStack React Query (like & comments islands)"]
end
subgraph Front["apps/front — Next.js 16 on :3000"]
MW["middleware.ts — protects /user/*"]
SES["lib/session.ts — jose session cookie (7d)"]
GQLC["lib/fetchGraphQL.ts — public / authenticated GraphQL client"]
UP["lib/upload.ts — Supabase Storage upload"]
RR["API Routes (google/callback, signout)"]
end
subgraph API["apps/api — NestJS on :8080"]
REST["AuthController (REST)"]
GRAPHQL["GraphQLModule (Apollo driver, code-first)"]
GUARDS["Passport guards / strategies (JWT + Google)"]
RES["Resolvers (Post, User, Comment, Like, Auth)"]
SVC["Services + PrismaService (PrismaPg adapter)"]
end
subgraph Data["Data & External Services"]
PG[("PostgreSQL — Neon (Prisma migrations)")]
SB["Supabase Storage (thumbnail-bucket-for-new-post)"]
GOOGLE["Google OAuth2 (accounts.google.com)"]
OBS["NestJS Observe (optional instrument)"]
end
UI --> MW
MW --> SES
UI --> SW
SW --> GQLC
SW --> UP
UI --> RQ --> GQLC
RQ --> SES
SW --> RR
RR --> REST
UI -->|"GET /auth/google/login · callback"| REST
GQLC -->|"POST /graphql (Bearer token from session)"| GRAPHQL
GRAPHQL --> GUARDS
GUARDS --> RES
RES --> SVC
SVC -->|"Prisma Client + PrismaPg adapter"| PG
UP --> SB
REST -->|"OAuth2 redirect"| GOOGLE
SVC -.->|"telemetry (placeholder key)"| OBS
new-post/
├── package.json # Root manifest — scripts delegate to Turborepo
├── pnpm-workspace.yaml # pnpm workspaces: apps/* + packages/* (+ allowed build scripts)
├── turbo.json # Pipeline: build deps order, lint, dev; env passthrough
├── pnpm-lock.yaml
├── .gitignore
│
└── apps/
├── api/ # NestJS GraphQL backend
│ ├── package.json # nest/vitest/prisma scripts, dependencies
│ ├── nest-cli.json # Nest CLI config
│ ├── tsconfig.json # module: nodenext, strict, decorators, target ES2023
│ ├── tsconfig.build.json
│ ├── prisma.config.ts # Prisma 7 config — schema path, migrations, seed, DATABASE_URL
│ ├── .prettierrc
│ ├── oxlint.json # oxlint rules (no-explicit-any off, no-floating-promises warn)
│ ├── vitest.config.ts # unit tests: **/*.spec.ts
│ ├── vitest.config.e2e.ts # e2e tests: **/*.e2e-spec.ts
│ ├── prisma/
│ │ ├── schema.prisma # User, Post, Comment, Tag, Like (+ PostTags m2m)
│ │ ├── migrations/ # 20260915072539_init, 20260917032721_update_cascade_delete
│ │ └── seed.ts # 10 users, 8 tags, 40 posts, 20 comments each (password "123")
│ ├── test/
│ │ └── app.e2e-spec.ts # supertest e2e for GET /
│ └── src/
│ ├── main.ts # bootstrap, CORS (FRONTEND_URL), ValidationPipe, PORT ?? 8080
│ ├── app.module.ts # GraphQL (Apollo, autoSchemaFile), Observe, feature modules
│ ├── constants.ts # DEFAULT_PAGE_SIZE = 12
│ ├── graphql/schema.gql # Auto-generated schema (do not modify)
│ ├── prisma/ # PrismaService + module (PrismaPg driver adapter)
│ ├── auth/ # controller (REST), resolver (GraphQL), service, jwt & google
│ │ # strategies, guards, DTOs, AuthPayload entity, AuthJwtPayload type
│ ├── user/ # user.resolver (createUser), user.service (argon2 hash), DTOs, entity
│ ├── post/ # post.resolver/service — CRUD + ownership checks, tags m2m
│ ├── comment/ # comment.resolver/service — list + create, paginated, newest first
│ ├── like/ # like.resolver/service — like/unlike/count/status
│ ├── tag/ # tag.module (empty — placeholder scaffolding only)
│ └── common/types.ts # Circular<T> helper type
│
└── front/ # Next.js 16 App Router frontend
├── package.json
├── next.config.ts # serverExternalPackages (jsdom, dompurify) + image remote patterns
├── postcss.config.mjs # @tailwindcss/postcss
├── components.json # shadcn config (base-rhea style, hugeicons)
├── tsconfig.json # @/* path alias
├── eslint.config.mjs # next/core-web-vitals + typescript
├── middleware.ts # guards /user/* — redirects to /auth/signin when no session
├── public/ # favicon, web manifest, apple-touch-icon
├── lib/
│ ├── constants.ts # BACKEND_URL (throws when missing), DEFAULT_PAGE_SIZE
│ ├── session.ts # createSession/getSession/deleteSession (jose, 7d cookie)
│ ├── fetchGraphQL.ts # fetchGraphQL + authFetchGraphQL helpers
│ ├── gqlQueries.ts # all GraphQL document strings (graphql-tag)
│ ├── upload.ts # Supabase thumbnail upload → public URL
│ ├── helpers.ts # transformTakeSkip (page → skip/take)
│ ├── zodSchemas/ # signUp, signIn, post, comment schemas
│ ├── actions/ # server actions: auth, postActions, commentActions, like
│ └── types/ # modelTypes, formState
├── components/ # hero, posts, postCard, navbar, profile, signInPanel, ui/* (shadcn)
└── app/
├── layout.tsx # fonts, metadata, Providers (React Query), navbars, Toaster
├── providers.tsx # QueryClientProvider
├── page.tsx # Home — Hero + paginated latest posts
├── api/
│ └── auth/ # google/callback (route), signout (route)
├── auth/ # layout (brand split), signin/, signup/
├── blog/[slug]/[id]/ # article page + _components (safeHtml, like, comments, ...)
└── user/ # layout, posts/ (dashboard), create-post/, posts/[id]/update/
| Model | Description | Key fields |
|---|---|---|
User |
Authenticated author/reader. email is unique; password nullable (Google users have none). |
id (PK), name, email (unique), bio?, avatar?, password?, createdAt, updatedAt |
Post |
Article written by a User; published toggles draft/publish; slug unique (only set by the seed script). |
id (PK), slug? (unique), title, content, thumbnail?, published, authorId (FK) |
Comment |
Reader comment on a Post; deleted with its post (onDelete: Cascade). |
id (PK), content, postId (FK, cascade), authorId (FK) |
Tag |
Reusable label; name unique. Shares an implicit _PostTags many-to-many with Post. |
id (PK), name (unique) |
Like |
"Heart" on a post; unique per (userId, postId); deleted with its post (onDelete: Cascade). |
id (PK), userId (FK), postId (FK, cascade), @@unique([userId, postId]) |
Migration
20260917032721_update_cascade_deletechanged the FK behavior ofComment.postIdandLike.postIdfromRESTRICTtoCASCADE(deleting a post deletes its comments and likes).Post.authorId,Comment.authorId, andLike.userIdremainRESTRICT.
erDiagram
User ||--o{ Post : "writes"
User ||--o{ Comment : "authors"
User ||--o{ Like : "gives"
Post ||--o{ Comment : "receives"
Post ||--o{ Like : "receives"
Post }o--o{ Tag : "tagged with (PostTags)"
User {
int id PK
string name
string email UK
string bio
string avatar
string password
datetime createdAt
datetime updatedAt
}
Post {
int id PK
string slug UK
string title
string content
string thumbnail
boolean published
int authorId FK
datetime createdAt
datetime updatedAt
}
Comment {
int id PK
string content
int postId FK
int authorId FK
datetime createdAt
datetime updatedAt
}
Tag {
int id PK
string name UK
}
Like {
int id PK
int userId FK
int postId FK
datetime createdAt
datetime updatedAt
}
| Relationship | Type | Cardinality | Notes |
|---|---|---|---|
User → Post |
One-to-many | 1 : N | Post.authorId FK; delete restricted |
User → Comment |
One-to-many | 1 : N | Comment.authorId FK; delete restricted |
User → Like |
One-to-many | 1 : N | Like.userId FK; delete restricted |
Post → Comment |
One-to-many | 1 : N | Comment.postId FK; onDelete: Cascade |
Post → Like |
One-to-many | 1 : N | Like.postId FK; onDelete: Cascade; unique (userId, postId) |
Post ↔ Tag |
Many-to-many | N : N | Implicit join table _PostTags; cascade on both sides |
sequenceDiagram
actor U as Reader
participant F as Next.js (signup page)
participant A as Server Action signUp
participant G as GraphQL API
participant S as UserService
participant DB as Prisma/PostgreSQL
U->>F: Submit sign-up form
F->>A: formData
A->>A: zod validation (password: 8+, letter, number, special)
A->>G: Mutation createUser(name, email, password)
G->>S: createUserInput
S->>S: argon2.hash(password)
S->>DB: INSERT user (hashed password)
DB-->>S: user
S-->>G: user
G-->>A: user
A-->>U: redirect("/auth/signin")
sequenceDiagram
actor U as User
participant F as Next.js (signin page)
participant A as Server Action signIn
participant G as GraphQL API (AuthResolver)
participant AS as AuthService
participant DB as Prisma/PostgreSQL
participant SES as session.ts
participant MW as middleware.ts
U->>F: Submit credentials
F->>A: email + password
A->>G: Mutation signIn
G->>AS: validateLocalUser(email, password)
AS->>DB: findUnique user by email
AS->>AS: argon2.verify(password hash)
AS->>AS: JwtService.signAsync({ sub: userId })
AS-->>A: { id, name, avatar, accessToken }
A->>SES: createSession({ user, accessToken })
SES->>SES: SignJWT(HS256, exp 7d) → httpOnly cookie
A-->>U: redirect("/")
U->>MW: GET /user/posts
MW->>SES: getSession()
SES-->>MW: session?.user
MW-->>U: allow /user/posts
sequenceDiagram
participant U as User
participant F as Next.js sign-in page
participant APIREST as NestJS AuthController
participant GOOGLE as Google OAuth2
participant STRAT as GoogleStrategy
participant AS as AuthService
participant DB as Prisma/PostgreSQL
participant RR as Next.js /api/auth/google/callback
participant SES as session.ts
U->>F: Click "Sign in with Google"
F->>APIREST: GET /auth/google/login (302)
APIREST->>GOOGLE: authorize redirect (client_id, scope)
GOOGLE-->>APIREST: GET /auth/google/callback?code=
APIREST->>STRAT: validate(email, name, avatar)
STRAT->>AS: validateGoogleUser({email, name, avatar, password:""})
AS->>DB: findUnique by email → create if missing
AS-->>STRAT: authUser (password stripped)
APIREST->>APIREST: login() → sign accessToken
APIREST-->>F: 302 {FRONTEND_URL}/api/auth/google/callback?accessToken=&userId=&name=&avatar=
F->>RR: GET callback (query params)
RR->>APIREST: GET /auth/verify-token (Authorization: Bearer)
APIREST-->>RR: 200 ok
RR->>SES: createSession({ user, accessToken })
RR-->>U: redirect("/")
sequenceDiagram
actor W as Writer
participant F as create-post page
participant SA as Server Action saveNewPost
participant Z as zod (PostFormSchema)
participant SB as Supabase Storage
participant G as GraphQL API
participant S as PostService
participant DB as Prisma/PostgreSQL
W->>F: Fill title, content, tags, thumbnail, publish toggle
F->>SA: formData
SA->>Z: safeParse (title 2–100, content ≥5, tags csv)
alt thumbnail provided
SA->>SB: upload(`${Date.now()}_${file.name}`)
SB-->>SA: public URL
end
SA->>G: Mutation createPost ({...fields, tags, thumbnail})
G->>S: create({createPostInput, authorId})
S->>DB: create post + tags connectOrCreate
DB-->>S: post
G-->>SA: post
SA-->>W: toast success → redirect /user/posts
sequenceDiagram
participant R as Reader (client)
participant C as Like button (client island)
participant RQ as TanStack React Query
participant SA as Server Action likePost
participant G as GraphQL API
participant S as LikeService
participant DB as Prisma/PostgreSQL
R->>C: Click heart
C->>RQ: mutate(true)
RQ->>RQ: onMutate — optimistic likeCount+1, userLikedPost=true
RQ->>SA: likePost(postId)
SA->>G: Mutation likePost(postId) [Bearer]
G->>S: likePost({postId, userId})
S->>DB: INSERT Like (unique userId_postId)
DB-->>S: created
S-->>G: true
G-->>SA: true
SA-->>RQ: true
RQ->>RQ: onSettled — invalidate GET_POST_LIKE_DATA
RQ-->>C: re-render with server count
alt duplicate like
S-->>G: BadRequestException("You already liked this post")
RQ->>RQ: onError — rollback to previous state
end
sequenceDiagram
participant W as Writer
participant P as /user/posts dashboard
participant SA as Server Action updatePost / deletePost
participant G as GraphQL API
participant S as PostService
participant DB as Prisma/PostgreSQL
W->>P: Choose Edit or Delete
alt Update
P->>SA: updatePost(formData, postId)
SA->>G: Mutation updatePost
G->>S: update({userId, updatePostInput})
S->>DB: findUnique(id + authorId)
alt owner
S->>DB: update + tags set:[] + connectOrCreate
DB-->>S: updated post
else not the owner
S-->>G: UnauthorizedException
end
G-->>SA: post / error
SA-->>W: toast → redirect /user/posts
else Delete
P->>SA: deletePost(postId)
SA->>G: Mutation deletePost
G->>S: delete({postId, userId})
S->>DB: findUnique(id + authorId) → delete (cascade comments & likes)
DB-->>S: deleted
G-->>SA: true
SA-->>W: toast → router.refresh()
end
No .env.example files exist in the repository. Every variable below is read directly from process.env in the code and is required unless marked optional. Missing required variables either throw at startup or break a feature at runtime.
| Variable | Required | Description | Used in |
|---|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string used by the Prisma driver adapter (PrismaPg) and prisma CLI (migrations + seed) |
prisma.config.ts, prisma.service.ts, prisma/seed.ts |
JWT_SECRET |
Yes | Secret for signing/verifying API access tokens (@nestjs/jwt) |
auth.module.ts, jwt.strategy.ts |
JWT_EXPIRES_IN |
No | Access-token lifetime (default "24h") |
auth.module.ts |
GOOGLE_CLIENT_ID |
Yes (Google SSO) | Google OAuth2 client ID | google.strategy.ts |
GOOGLE_CLIENT_SECRET |
Yes (Google SSO) | Google OAuth2 client secret | google.strategy.ts |
GOOGLE_CALLBACK_URL |
Yes (Google SSO) | Redirect URI registered in the Google console, e.g. http://localhost:8080/auth/google/callback |
google.strategy.ts |
FRONTEND_URL |
Yes | Allowed CORS origin and OAuth post-return destination, e.g. http://localhost:3000 |
main.ts, auth.controller.ts |
PORT |
No | API listen port (default 8080) |
main.ts |
# apps/api/.env
# PostgreSQL (e.g. Neon). Used by Prisma driver adapter, migrate, and seed.
DATABASE_URL="postgresql://user:password@host:5432/db?sslmode=require"
# JWT authentication
JWT_SECRET="<generate-a-long-random-string>"
JWT_EXPIRES_IN=24h
# Google OAuth2 (https://console.cloud.google.com/apis/credentials)
GOOGLE_CLIENT_ID="<your-client-id>.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="<your-client-secret>"
GOOGLE_CALLBACK_URL="http://localhost:8080/auth/google/callback"
# Frontend origin for CORS + post-OAuth redirect
FRONTEND_URL="http://localhost:3000"| Variable | Required | Description | Used in |
|---|---|---|---|
BACKEND_URL |
Yes | API base URL; the code throws if it is missing | lib/constants.ts |
SESSION_SECRET_KEY |
Yes | Secret used by jose to sign/verify the session cookie (HS256) |
lib/session.ts |
SUPABASE_URL |
Yes (upload feature) | Supabase project URL for the storage client | lib/upload.ts |
SUPABASE_API_KEY |
Yes (upload feature) | Supabase anon/key for the storage client (upload + getPublicUrl) | lib/upload.ts |
# apps/front/.env
# NestJS API origin (no trailing slash needed)
BACKEND_URL="http://localhost:8080"
# Secret for the jose HS256 session cookie (independent from the API JWT secret)
SESSION_SECRET_KEY="<generate-a-long-random-string>"
# Supabase project for thumbnail storage (https://supabase.com/dashboard)
SUPABASE_URL="https://your-project-ref.supabase.co"
SUPABASE_API_KEY="<your-supabase-anon-key>"
turbo.jsondeclaresBACKEND_URL,SESSION_SECRET_KEY,SUPABASE_URL, andSUPABASE_API_KEYas env passthrough for the build task, so they must be present in the environment (apps/front/.env) for a production build to succeed.
- Node.js ≥ 20 (the repo declares
packageManager: [email protected]; the API targets ES2023) - pnpm 12.3.3 — install with
corepack enableornpm i -g [email protected] - PostgreSQL — any reachable instance (local, Docker, or a serverless option like Neon, which the sample env uses)
- Google Cloud project — only if you want Google sign-in: create OAuth credentials and configure the redirect URIs
- Supabase project — only if you want thumbnail uploads: create a public storage bucket named
thumbnail-bucket-for-new-post
git clone https://github.com/auriorajaa/new-post.git
cd new-post
pnpm installpnpm install runs prisma generate through the API's postinstall script.
- Copy the samples from Environment Variables into
apps/api/.envandapps/front/.env, filling in real values. - Set
FRONTEND_URL=http://localhost:3000andGOOGLE_CALLBACK_URL=http://localhost:8080/auth/google/callbackfor local development.
cd apps/api
pnpm prisma migrate deploy # apply existing migrations to your DATABASE_URL
pnpm db:seed # 10 users, 8 tags, 40 posts, 20 comments eachUse
pnpm prisma migrate devinstead when you changeprisma/schema.prismaand want to create a new migration.
- In the Google Cloud console, create an OAuth 2.0 Client ID of type Web application.
- Add an authorized redirect URI:
http://localhost:8080/auth/google/callback(and your production variant). - Put the client ID / secret in
apps/api/.env. - Frontend route
/auth/signinalready links toBACKEND_URL/auth/google/login— no frontend change needed.
- Create a Supabase project.
- Create a public storage bucket named exactly
thumbnail-bucket-for-new-post(the code references it verbatim inlib/upload.ts). - Fill
SUPABASE_URLandSUPABASE_API_KEYinapps/front/.env.
From the repository root (Turborepo runs both apps in watch mode):
pnpm dev- Frontend:
http://localhost:3000 - API GraphQL playground:
http://localhost:8080/graphql - API health probe:
http://localhost:8080/→Hello World!
| Script | Command | Description |
|---|---|---|
dev |
turbo run dev |
Start both apps (api watch + front dev) with cache disabled |
build |
turbo run build |
Build all apps in dependency order |
lint |
turbo run lint |
Lint all apps |
| Script | Command | Description |
|---|---|---|
build |
nest build |
Compile TypeScript to dist/ |
deploy |
nest deploy |
Deploy via NestJS Mau (requires Mau setup) |
format |
prettier --write "src/**/*.ts" "test/**/*.ts" |
Format backend sources |
start |
nest start |
Run the app |
dev |
nest start --watch |
Run in watch mode |
start:debug |
nest start --debug --watch |
Run with Node.js inspector |
start:prod |
node dist/main |
Run the production build |
lint |
oxlint src/ test/ |
Lint with oxlint |
test |
vitest run |
Run unit tests (**/*.spec.ts) |
test:watch |
vitest |
Run unit tests in watch mode |
test:cov |
vitest run --coverage |
Run unit tests with coverage |
test:debug |
vitest --inspect-brk --no-file-parallelism |
Run unit tests under the inspector |
test:e2e |
vitest run --config ./vitest.config.e2e.ts |
Run e2e tests (**/*.e2e-spec.ts) |
postinstall |
prisma generate |
Regenerate Prisma Client after install |
db:seed |
tsx ./prisma/seed.ts |
Seed the database |
| Script | Command | Description |
|---|---|---|
dev |
next dev |
Start the dev server |
build |
next build |
Production build (needs the env vars listed in turbo.json) |
start |
next start |
Serve the production build |
lint |
eslint |
Lint with next/core-web-vitals + TypeScript rules |
| Route | Auth | Description |
|---|---|---|
/ |
Public | Home — Hero section + latest posts, paginated via ?page= |
/auth/signin |
Public | Sign-in form (email/password) + "Continue with Google" |
/auth/signup |
Public | Registration form |
/blog/[slug]/[id] |
Public (engagements auth) | Article page — sanitized body, tags, like button, paginated comments |
/user/posts |
Protected | Writer dashboard — manage own posts (view, edit, delete) |
/user/create-post |
Protected | Create a new post (or save as draft) |
/user/posts/[id]/update |
Protected | Edit an existing post with pre-filled values |
/api/auth/google/callback |
Public (origin-restricted data) | Verifies the JWT returned by the API, creates the session, redirects to / |
/api/auth/signout |
Public | Deletes the session cookie and redirects to / |
The middleware
config.matcheris/user/:path*; all/user/*routes 302 →/auth/signinwhen no valid session exists.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ |
— | Health probe, returns "Hello World!" |
GET |
/auth/google/login |
Google OAuth | Starts the Google OAuth2 flow (passport-google-oauth20) |
GET |
/auth/google/callback |
Google OAuth | OAuth callback; exchanges the profile for a JWT and redirects to FRONTEND_URL/api/auth/google/callback?... |
GET |
/auth/verify-token |
Bearer JWT (JwtAuthGuard) |
Returns "ok"; used by the frontend to validate a Google-flow access token |
POST |
/graphql |
Mixed (per resolver) | GraphQL endpoint — see table below |
Queries
| Query | Auth | Description |
|---|---|---|
posts(skip, take) |
Public | Paginated list of all posts (default take = 12) |
postCount |
Public | Total number of posts (for pagination) |
getPostById(id) |
Public | Single post incl. author, tags |
getUserPosts(skip, take) |
JWT | Current user's posts with _count { likes, comments } |
userPostCount |
JWT | Number of posts owned by the current user |
getPostComments(postId, take = 12, skip = 0) |
Public | Comments for a post, newest first, incl. author |
postCommentCount(postId) |
Public | Total comments on a post |
postLikesCount(postId) |
Public | Total likes on a post |
userLikedPost(postId) |
JWT | Whether the current user liked the post |
Mutations
| Mutation | Auth | Description |
|---|---|---|
signIn(signInInput) |
Public | Validates email/password (Argon2) and returns { id, name, avatar, accessToken } |
createUser(createUserInput) |
Public | Registers a user (server hashes password with Argon2) |
createPost(createPostInput) |
JWT | Creates a post as the current user; tags via connect-or-create |
updatePost(updatePostInput) |
JWT | Updates a post owned by the current user; re-syncs tags |
deletePost(postId) |
JWT | Deletes a post owned by the current user (cascades comments & likes) |
createComment(createCommentInput) |
JWT | Adds a comment to a post as the current user |
likePost(postId) |
JWT | Likes a post; errors with 400 if already liked |
unlikePost(postId) |
JWT | Removes the like; errors with 400 if the like does not exist |
- Registration hashes the password server-side with
argon2.hash()beforeprisma.user.create. GraphQL never returns thepasswordfield. - Sign-in looks the user up by email, runs
argon2.verify(), then signs a JWT containing{ sub: userId }(expiry fromJWT_EXPIRES_IN). The returned profile never includes the password hash. - Sessions are frontend-owned: the access token plus a lightweight user snapshot (
id,name,avatar) are packed into ajoseHS256 JWT stored in anhttpOnly/sameSite=laxcookie namedsession, expiring in 7 days. Every protected GraphQL call re-attachesAuthorization: Bearer <accessToken>. - Google flow never stores a password.
validateGoogleUserfinds the user by email or creates one (with an empty password), then strips the password before returning. The API hands the JWT back to the frontend via a redirect query string, which the frontend double-checks againstGET /auth/verify-tokenbefore issuing its cookie.
updatePostanddeletePostfirst assert the target post matches the current user (findUnique({ id, authorId })). Any mismatch throwsUnauthorizedException— a user can never edit or delete another user's post.- Tags are stored as a many-to-many relation. On create,
connectOrCreateupserts each tag by its uniquename. On update, the relation is fully replaced:set: []clears old links, thenconnectOrCreatere-adds all submitted tags. - Slugs are optional in the schema and only produced by the seed script's
generateSlug(); the API/CRUD layer does not compute slugs, and routing resolves posts by numericidunder/blog/[slug]/[id].
- A database-level unique constraint
@@unique([userId, postId])guarantees a user likes a post at most once. A duplicate insert is caught and rethrown asBadRequestException("You already liked this post"); a missing like on unlike becomesBadRequestException("Like not found"). - Like counts and the current user's like status come from
postLikesCount/userLikedPostand are cached client-side by React Query, with an optimistic update that rolls back on error.
- Only authenticated users can create comments; the list is public.
- Comments are fetched newest-first (
orderBy createdAt desc) withtake/skippagination, default page size 12 (DEFAULT_PAGE_SIZE). - Comment authors are embedded server-side (
include: { author: true }), so the UI renders name + avatar without extra round-trips.
publishedis a boolean the author chooses at write time ("Publish now" or save as a draft). Draft state is surfaced as a badge in the "Your posts" dashboard. Note: the publicfindAll/postsquery does not filter onpublished, so all posts appear in the public feed.- Post content is authored as markdown-like text and sanitized on the client with
isomorphic-dompurifybefore rendering, mitigating XSS from stored HTML.
- All background IO happens in server actions (
lib/actions/*) that fetch the GraphQL API withfetch; public data usesfetchGraphQL, user-scoped data usesauthFetchGraphQLwhich reads the session cookie and injects the bearer token. - Interactive islands (Like button, Comments) call those server actions through TanStack React Query, which provides optimistic updates, cancellation, and invalidation; forms use React's
useActionState+sonnertoasts.
There are exactly two roles: anonymous and authenticated (any signed-in user). There is no admin role or role hierarchy in the code.
| Route / resource | Anonymous | Authenticated |
|---|---|---|
/, /blog/[slug]/[id] |
Read | Read |
/auth/signin, /auth/signup |
Use | — |
/user/* (all) |
Redirected to /auth/signin (middleware) |
Access |
| Read posts & counts | Yes | Yes |
| Read comments & like counts | Yes | Yes |
userLikedPost |
— | Yes |
| Create/update/delete post | — | Yes (owner only for update/delete) |
| Create comment | — | Yes |
| Like / unlike post | — | Yes |
| Sign in / sign up / Google SSO | Yes | — |
| Resolver | Guard |
|---|---|
getUserPosts, userPostCount, createPost, updatePost, deletePost |
JwtAuthGuard (@UseGuards) |
createComment, likePost, unlikePost, userLikedPost |
JwtAuthGuard (@UseGuards) |
posts, postCount, getPostById, getPostComments, postCommentCount, postLikesCount, createUser, signIn |
Public |
GET /auth/verify-token |
JwtAuthGuard (REST) |
GET /auth/google/login, GET /auth/google/callback |
GoogleAuthGuard |
JwtAuthGuardis a GraphQL-aware wrapper (getRequestextractsreqfrom the GraphQL context) around the Passport JWT strategy.
| Integration | Purpose | Flow |
|---|---|---|
| Supabase Storage | Post thumbnail hosting | The saveNewPost/updatePost server action uploads the image to the public bucket thumbnail-bucket-for-new-post with a timestamped filename, then stores the returned public URL in the post's thumbnail field. The bucket name is hard-coded in lib/upload.ts. |
| Google OAuth2 | Social sign-in | Passport google-oauth20 strategy on the API. Browser → /auth/google/login (302 to Google) → /auth/google/callback → API exchanges profile for a JWT → redirect to the frontend callback route → frontend verifies the token against /auth/verify-token → writes the session cookie. |
| PostgreSQL (Neon) | Primary datastore | Prisma 7 with the pg driver adapter (PrismaPg). DATABASE_URL points at a Neon pooled instance in the sample env; migrations live in apps/api/prisma/migrations. |
| NestJS Observe | (Optional) Observability | createObserveModule() instruments the app; configured in app.module.ts with placeholder YOUR_APP_KEY/YOUR_APP_SECRET and serviceId: "api". Wiring exists out of the box but is inert until real credentials are supplied. |
- Build both apps:
pnpm build(verify all env vars fromturbo.jsonare set on the host). - Migrations applied:
pnpm prisma migrate deployagainst the productionDATABASE_URL. - Seed/verify data: run
pnpm db:seedif the production DB should be populated. - API: set
FRONTEND_URLto the deployed frontend origin; enable CORS for it. - Google OAuth: add the production
GOOGLE_CALLBACK_URL(e.g.https://api.example.com/auth/google/callback) to the Google console authorized redirect URIs. - Supabase: bucket
thumbnail-bucket-for-new-postmust exist and be public; theSUPABASE_*vars must point at the production project. - Session/API secrets: rotate
SESSION_SECRET_KEYandJWT_SECRET(they are stored in non-committed.envfiles and must never enter version control). - Frontend: set
metadataBase/site URL inapp/layout.tsxto the production domain (https://the-post-journal.vercel.appis hard-coded today). - Set the
PORTfor the API if not8080.
- Frontend → Vercel (the project is already Vercel-ready:
.vercelis gitignored, Next 16 output works out of the box). Configure the four frontend env vars in the Vercel dashboard. - API → Railway / Render / Fly.io (Node 20+). Run
pnpm install --prod && pnpm build && node dist/main(or the Dockerized equivalent). Managed PostgreSQL works here as well. - Database → Neon (serverless Postgres, already reflected in the sample connection string) or any managed Postgres.
- Kept lightweight by design: no queue workers, no redis, no CDN beyond the image host + Supabase public URLs, so a two-service deploy above is sufficient.
- Turborepo + pnpm workspaces — Two independently versioned apps share one lockfile and a single
turbo runfan-out fordev/build/lint; there is no sharedpackages/*package today despite the workspace glob. - Code-first GraphQL over REST for CRUD — NestJS
@Resolver/@ObjectType/@InputTypedecorators auto-generateschema.gqlat runtime; the REST surface is reserved for OAuth callbacks and health/token checks. DTOs double as bothclass-validatorand GraphQL input types. - Prisma driver adapters (
PrismaPg) — The API uses the engine-optional@prisma/adapter-pg, passingDATABASE_URLto the pg adapter rather than a classic prism url in the schema datasource block (Prisma 7 style). - Frontend-owned session wrapping API JWTs — Rather than storing the bearer token in JS-accessible storage, the frontend signs its own
httpOnlyHS256 cookie that embeds both the user profile and the API access token. The JWT remains the only credential the API trusts. - All writes go through Next.js server actions — Forms never hit GraphQL directly from the browser; each mutation is validated with zod, does optional Supabase I/O, then calls the API with the session token. This concentrates validation, auth, and side effects in one layered server action per use case.
- Optimistic engagement UI — Likes/comments are client islands using TanStack React Query with cancel-on-mutate, optimistic
onMutateupdates, rollback on error, andinvalidateQuerieson settle — giving an instant feel while staying eventually consistent withpostLikesCount/postCommentCount. - Security posture is put inline — Argon2 hashing, GraphQL-aware JWT guards, ownership assertions per mutation, unique constraints for likes, plus server- and client-side sanitization (
DOMPurify) of rendered post content. - Seed is a miniature content engine — Instead of static fixtures,
seed.tsprocedurally composes topical titles, conversational Markdown bodies, unique slugs, and comment threads from tag-keyed word pools, producing believable demo content deterministically per run.
Run pnpm db:seed inside apps/api to populate the database with:
| Entity | Quantity | Details |
|---|---|---|
| Users | 10 | Faker names/bios/avatars, emails at example{i}.test, all with password 123 (Argon2-hashed) |
| Tags | 8 | Technology, Design, Business, Lifestyle, Culture, Science, Productivity, Travel |
| Posts | 40 | Topical titles w/ unique slugs, procedural Markdown content, Picsum thumbnails, published with ~85% probability, 1–3 tags each |
| Comments | 20 per post (~800) | Random real-feeling comment templates from 15 rotating authors |
Default credentials:
| Field | Value |
|---|---|
any seeded user, e.g. the first generated user at example0.test |
|
| Password | 123 |
The exact seeded email addresses are random per run; check your database after seeding, or use a Google account via OAuth.
- Install:
pnpm install - Open both apps in development:
pnpm dev - Keep things green before submitting:
- Lint:
pnpm lint(oxlint for the API, ESLintnext/core-web-vitalsfor the frontend) - Type-check/build:
pnpm build - Tests (API):
pnpm --filter api testandpnpm --filter api test:e2e
- Lint:
Formatting is handled by Prettier (pnpm --filter api format); the API's tsconfig uses module: nodenext, so relative imports in apps/api are written with an explicit .js extension.
This repository is licensed under the ISC license (root package.json). The apps/api package declares "license": "UNLICENSED" and is marked private; the apps/front package is private with no declared license.








