-
+
+ {/* ── Hero ──────────────────────────────────────────────────────────── */}
+
+
+
+
+ Practical Engineering Stories
+
+
+ Fast backend patterns, frontend craft, and product lessons from
+ shipping real systems.
+
+
+
+ {topCategories.slice(0, 6).map((c) => (
+
+
+ {c.name} ({c.count})
+
+
+ ))}
+
+
+
+
+ {/* ── Featured posts ─────────────────────────────────────────────────── */}
+ {featured.length > 0 && (
+
+
+
+
Featured
+
+
+ {/* Primary featured */}
+
+
+ Featured pick
+
+ {featured[0].featuredImageUrl && (
+
+
+
+
+
+ )}
+
+
+ {featured[0].title}
+
+
+
+ {featured[0].excerpt}
+
+
+
+ {featured[0].views.toLocaleString()} views
+
+
+
+ {/* Secondary featured */}
+
+ {featured.slice(1).map((post) => (
+
+ {post.featuredImageUrl && (
+
+
+
+ )}
+
+
+
+ {post.title}
+
+
+
+ {post.excerpt}
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* ── Main content + sidebar ─────────────────────────────────────────── */}
+
+
+ {/* Filter form */}
+
+
+
+
+ {/* Post grid */}
+ {posts.length === 0 ? (
+
+
+ No posts found for this filter.
+
+
+
+ ) : (
+
+ {posts.map((post) => (
+
+ ))}
+
+ )}
+
+ {/* Pagination */}
+
+
+
+
+
+ {/* Sidebar */}
+
+
- >
);
}
diff --git a/frontend/components/navbar.tsx b/frontend/components/navbar.tsx
new file mode 100644
index 0000000..98f4634
--- /dev/null
+++ b/frontend/components/navbar.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import Link from "next/link";
+import { useAuth } from "@/lib/auth";
+import { Button } from "@/components/ui/button";
+import { BookOpen, LayoutDashboard, LogOut, LogIn } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+
+export function Navbar() {
+ const { currentUser, logout } = useAuth();
+ const router = useRouter();
+
+ const handleLogout = async () => {
+ await logout();
+ toast.success("Logged out successfully");
+ router.push("/");
+ router.refresh();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/pagination.tsx b/frontend/components/pagination.tsx
new file mode 100644
index 0000000..d9ce06d
--- /dev/null
+++ b/frontend/components/pagination.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+
+interface PaginationProps {
+ page: number;
+ totalPages: number;
+ total: number;
+}
+
+export function Pagination({ page, totalPages, total }: PaginationProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ if (totalPages <= 1) return null;
+
+ const goToPage = (p: number) => {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("page", String(p));
+ router.push(`?${params.toString()}`);
+ };
+
+ // Show at most 5 page numbers
+ const getPageNumbers = () => {
+ const pages: number[] = [];
+ const start = Math.max(1, page - 2);
+ const end = Math.min(totalPages, start + 4);
+ for (let i = start; i <= end; i++) pages.push(i);
+ return pages;
+ };
+
+ return (
+
+
+ Page {page} of {totalPages} ({total} posts)
+
+
+
+
+ {getPageNumbers().map((p) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/components/post-card.tsx b/frontend/components/post-card.tsx
new file mode 100644
index 0000000..258edf6
--- /dev/null
+++ b/frontend/components/post-card.tsx
@@ -0,0 +1,72 @@
+import Link from "next/link";
+import Image from "next/image";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent, CardFooter } from "@/components/ui/card";
+import { Eye } from "lucide-react";
+import { BlogPost } from "@/lib/types";
+
+interface PostCardProps {
+ post: BlogPost;
+}
+
+export function PostCard({ post }: PostCardProps) {
+ return (
+
+ {post.featuredImageUrl && (
+
+
+
+
+
+ )}
+
+
+ {post.categories.length > 0 && (
+
+ {post.categories.slice(0, 3).map((cat) => (
+
+ {cat}
+
+ ))}
+
+ )}
+
+
+
+ {post.title}
+
+
+
+ {post.excerpt && (
+
+ {post.excerpt}
+
+ )}
+
+
+
+
+ {post.tags.slice(0, 3).map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+
+ {post.views.toLocaleString()}
+
+
+
+ );
+}
diff --git a/frontend/components/post-filter-form.tsx b/frontend/components/post-filter-form.tsx
new file mode 100644
index 0000000..b8e68c0
--- /dev/null
+++ b/frontend/components/post-filter-form.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useCallback } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Search, X } from "lucide-react";
+import { TagCloudItem } from "@/lib/types";
+
+interface PostFilterFormProps {
+ topCategories: TagCloudItem[];
+ currentQ: string;
+ currentCategory: string;
+ currentSort: string;
+ currentTags: string;
+}
+
+export function PostFilterForm({
+ topCategories,
+ currentQ,
+ currentCategory,
+ currentSort,
+ currentTags,
+}: PostFilterFormProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const updateParam = useCallback(
+ (updates: Record
) => {
+ const params = new URLSearchParams(searchParams.toString());
+ for (const [key, val] of Object.entries(updates)) {
+ if (val) {
+ params.set(key, val);
+ } else {
+ params.delete(key);
+ }
+ }
+ params.set("page", "1");
+ router.push(`?${params.toString()}`);
+ },
+ [router, searchParams]
+ );
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const fd = new FormData(e.currentTarget);
+ updateParam({
+ q: fd.get("q") as string,
+ tags: fd.get("tags") as string,
+ });
+ };
+
+ const clearFilters = () => {
+ router.push("/");
+ };
+
+ const hasFilters = currentQ || currentCategory || currentTags || (currentSort && currentSort !== "newest");
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..034b93f
--- /dev/null
+++ b/frontend/components/ui/alert-dialog.tsx
@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..7d1322d
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,95 @@
+/**
+ * API helpers for server components (forwards cookies) and client components (credentials: include).
+ */
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
+
+// ─── Server-side fetch (use in Server Components / Route Handlers) ─────────────
+
+export async function apiFetch(
+ path: string,
+ init?: RequestInit
+): Promise {
+ // Dynamic import so this module can also be imported in client components
+ // The `cookies` call only runs on the server side
+ let cookieHeader = "";
+ try {
+ const { cookies } = await import("next/headers");
+ const store = await cookies();
+ cookieHeader = store
+ .getAll()
+ .map((c) => `${c.name}=${c.value}`)
+ .join("; ");
+ } catch {
+ // We're on the client — skip cookie forwarding
+ }
+
+ const res = await fetch(`${API_URL}${path}`, {
+ ...init,
+ cache: "no-store",
+ headers: {
+ "Content-Type": "application/json",
+ ...(cookieHeader ? { Cookie: cookieHeader } : {}),
+ ...(init?.headers as Record | undefined),
+ },
+ });
+
+ if (!res.ok) {
+ let msg = `API error ${res.status}`;
+ try {
+ const body = await res.json();
+ msg = body?.error?.message || body?.message || msg;
+ } catch {
+ /* ignore */
+ }
+ throw new ApiError(msg, res.status);
+ }
+
+ // Some endpoints return 204 No Content
+ const text = await res.text();
+ if (!text) return undefined as T;
+ return JSON.parse(text) as T;
+}
+
+// ─── Client-side fetch (use in Client Components) ─────────────────────────────
+
+export async function clientFetch(
+ path: string,
+ init?: RequestInit
+): Promise {
+ const res = await fetch(`${API_URL}${path}`, {
+ ...init,
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers as Record | undefined),
+ },
+ });
+
+ if (!res.ok) {
+ let msg = `API error ${res.status}`;
+ try {
+ const body = await res.json();
+ msg = body?.error?.message || body?.message || msg;
+ } catch {
+ /* ignore */
+ }
+ throw new ApiError(msg, res.status);
+ }
+
+ const text = await res.text();
+ if (!text) return undefined as T;
+ return JSON.parse(text) as T;
+}
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public status: number
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+export { API_URL };
diff --git a/frontend/lib/auth.tsx b/frontend/lib/auth.tsx
new file mode 100644
index 0000000..b078070
--- /dev/null
+++ b/frontend/lib/auth.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { clientFetch, ApiError } from "./api";
+import { CurrentUser, UserRole } from "./types";
+
+interface AuthContextValue {
+ currentUser: CurrentUser | null;
+ loading: boolean;
+ login: (email: string, password: string) => Promise;
+ register: (name: string, email: string, password: string) => Promise;
+ logout: () => Promise;
+ refresh: () => Promise;
+}
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [currentUser, setCurrentUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ /** Decode the JWT access token stored in the browser cookie (non-httpOnly portion).
+ * Since accessToken is httpOnly, we detect auth state by calling a lightweight
+ * authenticated endpoint. If it returns 401 → not logged in. */
+ const refresh = useCallback(async () => {
+ try {
+ // Call GET /blog-posts?pageSize=1 — requires auth. If cookie is valid, returns data.
+ // We only care about the response headers/status here, not the body.
+ const data = await clientFetch<{
+ posts?: unknown[];
+ total?: number;
+ // The JWT payload fields we need come from the cookie;
+ // we parse them via a lightweight /auth/me-style endpoint.
+ // Since we don't have /auth/me, we use the blog-posts endpoint response
+ // and get user info from a separate call to /users (ADMIN only) or just
+ // decode what we need from the refresh token.
+ // Simpler: call GET /blog-posts and use the response to confirm auth,
+ // then store minimal info from localStorage if previously set.
+ }>(
+ "/blog-posts?pageSize=1"
+ );
+ // Auth succeeded — try to restore user from sessionStorage
+ const cached = sessionStorage.getItem("currentUser");
+ if (cached) {
+ setCurrentUser(JSON.parse(cached));
+ }
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 401) {
+ setCurrentUser(null);
+ sessionStorage.removeItem("currentUser");
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const login = useCallback(async (email: string, password: string) => {
+ const data = await clientFetch<{
+ success: boolean;
+ user?: CurrentUser;
+ accessToken?: string;
+ }>("/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ email, password }),
+ });
+ // The NestJS login returns { success, accessToken, user } for JSON clients
+ if (data.user) {
+ // Normalize role to uppercase enum
+ const user: CurrentUser = {
+ sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
+ email: data.user.email,
+ role: (data.user.role as string).toUpperCase() as UserRole,
+ name: data.user.name,
+ };
+ setCurrentUser(user);
+ sessionStorage.setItem("currentUser", JSON.stringify(user));
+ }
+ }, []);
+
+ const register = useCallback(
+ async (name: string, email: string, password: string) => {
+ const data = await clientFetch<{
+ success: boolean;
+ user?: CurrentUser;
+ }>("/auth/register", {
+ method: "POST",
+ body: JSON.stringify({ name, email, password }),
+ });
+ if (data.user) {
+ const user: CurrentUser = {
+ sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
+ email: data.user.email,
+ role: (data.user.role as string).toUpperCase() as UserRole,
+ name: data.user.name,
+ };
+ setCurrentUser(user);
+ sessionStorage.setItem("currentUser", JSON.stringify(user));
+ }
+ },
+ []
+ );
+
+ const logout = useCallback(async () => {
+ try {
+ await clientFetch("/auth/logout", { method: "POST" });
+ } catch {
+ /* ignore */
+ }
+ setCurrentUser(null);
+ sessionStorage.removeItem("currentUser");
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used within AuthProvider");
+ return ctx;
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
new file mode 100644
index 0000000..f9ca836
--- /dev/null
+++ b/frontend/lib/types.ts
@@ -0,0 +1,86 @@
+export enum PostStatus {
+ DRAFT = "draft",
+ PUBLISHED = "published",
+ ARCHIVED = "archived",
+}
+
+export enum ContentFormat {
+ MARKDOWN = "markdown",
+ HTML = "html",
+}
+
+export enum UserRole {
+ ADMIN = "ADMIN",
+ MANAGER = "MANAGER",
+ MEMBER = "MEMBER",
+}
+
+export interface User {
+ id: string;
+ email: string;
+ name?: string;
+ role: UserRole;
+ isActive: boolean;
+ createdAt: string;
+}
+
+export interface FeaturedImage {
+ url: string;
+ alt: string;
+}
+
+export interface BlogPost {
+ id: string;
+ title: string;
+ slug: string;
+ status: PostStatus;
+ excerpt: string;
+ content: string;
+ contentFormat: ContentFormat;
+ authorId: string;
+ author?: Pick;
+ featuredImageUrl?: string;
+ featuredImageAlt?: string;
+ isFeatured: boolean;
+ views: number;
+ tags: string[];
+ categories: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PaginatedResult {
+ items: T[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+}
+
+export interface TagCloudItem {
+ name: string;
+ count: number;
+}
+
+export interface CreatePostDto {
+ title: string;
+ slug?: string;
+ excerpt?: string;
+ content: string;
+ contentFormat?: ContentFormat;
+ status?: PostStatus;
+ featuredImageUrl?: string;
+ featuredImageAlt?: string;
+ isFeatured?: boolean;
+ tags?: string; // comma-separated string
+ categories?: string; // comma-separated string
+}
+
+export interface CurrentUser {
+ sub: string;
+ email: string;
+ role: UserRole;
+ name?: string;
+ iat?: number;
+ exp?: number;
+}
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index e9ffa30..6fe4c82 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,7 +1,22 @@
import type { NextConfig } from "next";
+import path from "path";
const nextConfig: NextConfig = {
- /* config options here */
+ turbopack: {
+ root: path.resolve(__dirname),
+ },
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "images.unsplash.com",
+ },
+ {
+ protocol: "https",
+ hostname: "**.unsplash.com",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b8d25ae..4f2b7be 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,10 +8,12 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
+ "@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.574.0",
+ "marked": "^17.0.3",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
@@ -3757,6 +3759,31 @@
"tailwindcss": "4.1.18"
}
},
+ "node_modules/@tailwindcss/typography": {
+ "version": "0.5.19",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
+ "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "6.0.10"
+ },
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
+ }
+ },
+ "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
+ "version": "6.0.10",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+ "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -5390,7 +5417,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
- "dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -8565,6 +8591,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/marked": {
+ "version": "17.0.3",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz",
+ "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -10888,7 +10926,6 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
- "dev": true,
"license": "MIT"
},
"node_modules/tapable": {
@@ -11453,7 +11490,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/validate-npm-package-name": {
diff --git a/frontend/package.json b/frontend/package.json
index 2f4f6b7..9405137 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,10 +9,12 @@
"lint": "eslint"
},
"dependencies": {
+ "@tailwindcss/typography": "^0.5.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.574.0",
+ "marked": "^17.0.3",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
diff --git a/src/main.ts b/src/main.ts
index 1f4d27d..b9aa2a7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -10,6 +10,14 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
+ // ─── CORS — allow Next.js frontend ──────────────────────────────────────────
+ app.enableCors({
+ origin: process.env.FRONTEND_URL || 'http://localhost:3000',
+ credentials: true,
+ methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
+ allowedHeaders: ['Content-Type', 'Authorization', 'x-csrf-token'],
+ });
+
// ─── Static assets ──────────────────────────────────────────────────────────
app.useStaticAssets(join(process.cwd(), 'public'));