Next.js 15 TypeScript: Full-Stack Web Application Development
Next.js 15 with TypeScript represents the cutting edge of full-stack React development. This comprehensive guide covers the new App Router, Server Components, streaming, API routes, and production deployment strategies.
Why Choose Next.js 15 with TypeScript?
- App Router: New routing system with layouts and nested routing
- Server Components: Server-side rendering by default with React 18
- Streaming: Progressive loading with Suspense boundaries
- Type Safety: Full TypeScript integration throughout the stack
- Performance: Automatic optimization and code splitting
Step 1: Project Setup and Configuration
Set up a Next.js 15 TypeScript project with modern tooling:
# Create Next.js project with TypeScript template
npx create-next-app@latest my-nextjs-app --typescript --tailwind --eslint --app
cd my-nextjs-app
# Install additional dependencies
npm install @next/bundle-analyzer
npm install @vercel/analytics @vercel/speed-insights
npm install next-auth
npm install @auth/prisma-adapter prisma @prisma/client
npm install zod @hookform/resolvers react-hook-form
npm install lucide-react @radix-ui/react-slot
npm install class-variance-authority clsx tailwind-merge
npm install framer-motion
npm install date-fns
# Install development dependencies
npm install -D @types/node
npm install -D prisma
npm install -D @storybook/nextjs storybook
npm install -D husky lint-staged
npm install -D jest @testing-library/react @testing-library/jest-dom
npm install -D playwright @playwright/test
Configure Next.js for optimal development and production:
/** @type {import('next').NextConfig} */
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
const nextConfig = {
experimental: {
typedRoutes: true,
serverComponentsExternalPackages: ["prisma", "@prisma/client"],
ppr: true, // Partial Prerendering
},
// Image optimization
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.amazonaws.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
formats: ["image/webp", "image/avif"],
},
// Security headers
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
];
},
// Redirects
async redirects() {
return [
{
source: "/home",
destination: "/",
permanent: true,
},
];
},
// Rewrites for API
async rewrites() {
return [
{
source: "/api/v1/:path*",
destination: "/api/:path*",
},
];
},
// Bundle optimization
webpack: (config, { dev, isServer }) => {
// Optimize bundle size
if (!dev && !isServer) {
config.optimization.splitChunks = {
...config.optimization.splitChunks,
cacheGroups: {
...config.optimization.splitChunks.cacheGroups,
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
priority: 10,
chunks: "initial",
},
},
};
}
return config;
},
// Logging
logging: {
fetches: {
fullUrl: true,
},
},
// Output
output: "standalone",
// Compiler options
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
};
module.exports = withBundleAnalyzer(nextConfig);
next.config.js
Step 2: Type Definitions and App Architecture
Create comprehensive TypeScript definitions for the application:
// Base types
export interface BaseEntity {
id: string;
createdAt: Date;
updatedAt: Date;
}
// User types
export interface User extends BaseEntity {
email: string;
name: string;
username: string;
avatar?: string;
bio?: string;
role: UserRole;
isActive: boolean;
emailVerified: boolean;
preferences: UserPreferences;
}
export enum UserRole {
ADMIN = "ADMIN",
USER = "USER",
MODERATOR = "MODERATOR",
}
export interface UserPreferences {
theme: "light" | "dark" | "system";
language: string;
timezone: string;
notifications: {
email: boolean;
push: boolean;
marketing: boolean;
};
}
// Post types
export interface Post extends BaseEntity {
title: string;
content: string;
excerpt?: string;
slug: string;
status: PostStatus;
featuredImage?: string;
seoTitle?: string;
seoDescription?: string;
publishedAt?: Date;
author: User;
authorId: string;
category: Category;
categoryId: string;
tags: Tag[];
viewCount: number;
likeCount: number;
commentCount: number;
}
export enum PostStatus {
DRAFT = "DRAFT",
PUBLISHED = "PUBLISHED",
ARCHIVED = "ARCHIVED",
}
export interface Category extends BaseEntity {
name: string;
slug: string;
description?: string;
color?: string;
postsCount: number;
}
export interface Tag extends BaseEntity {
name: string;
slug: string;
color?: string;
}
export interface Comment extends BaseEntity {
content: string;
author: User;
authorId: string;
post: Post;
postId: string;
parent?: Comment;
parentId?: string;
replies: Comment[];
status: CommentStatus;
likeCount: number;
}
export enum CommentStatus {
PENDING = "PENDING",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
SPAM = "SPAM",
}
// API types
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
errors?: Record<string, string[]>;
meta?: {
pagination?: PaginationInfo;
total?: number;
timestamp: string;
};
}
export interface PaginationInfo {
page: number;
limit: number;
total: number;
pages: number;
hasNext: boolean;
hasPrev: boolean;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationInfo;
}
// Form types
export interface CreatePostForm {
title: string;
content: string;
excerpt?: string;
categoryId: string;
tagIds: string[];
featuredImage?: string;
seoTitle?: string;
seoDescription?: string;
status: PostStatus;
}
export interface UpdatePostForm extends Partial<CreatePostForm> {
id: string;
}
export interface CreateCommentForm {
content: string;
postId: string;
parentId?: string;
}
export interface UserProfileForm {
name: string;
username: string;
bio?: string;
avatar?: string;
preferences: UserPreferences;
}
// Search types
export interface SearchParams {
q?: string;
category?: string;
tags?: string[];
author?: string;
status?: PostStatus;
sortBy?:
| "createdAt"
| "updatedAt"
| "publishedAt"
| "viewCount"
| "likeCount";
sortOrder?: "asc" | "desc";
page?: number;
limit?: number;
}
export interface SearchResult {
posts: Post[];
categories: Category[];
tags: Tag[];
users: User[];
total: number;
}
// Component types
export interface PageProps {
params: { [key: string]: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export interface LayoutProps {
children: React.ReactNode;
params: { [key: string]: string };
}
export interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
// Utility types
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
// Next.js specific types
export type SearchParamsType = { [key: string]: string | string[] | undefined };
export type RouteContext<T = {}> = {
params: T;
};
export type ApiRouteContext<T = {}> = {
params: T;
};
// Metadata types
export interface PageMetadata {
title?: string;
description?: string;
keywords?: string[];
openGraph?: {
title?: string;
description?: string;
images?: string[];
type?: string;
};
twitter?: {
card?: string;
title?: string;
description?: string;
images?: string[];
};
alternates?: {
canonical?: string;
};
}
// Auth types (NextAuth)
export interface AuthSession {
user?: {
id: string;
email: string;
name: string;
username: string;
avatar?: string;
role: UserRole;
};
expires: string;
}
export interface JWTPayload {
id: string;
email: string;
name: string;
username: string;
role: UserRole;
iat?: number;
exp?: number;
}
src/types/index.ts
Step 3: App Router Structure and Layouts
Create a comprehensive App Router structure with nested layouts:
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { ThemeProvider } from 'next-themes'
import { cn } from '@/lib/utils'
import { Toaster } from '@/components/ui/toaster'
import { AuthProvider } from '@/components/providers/auth-provider'
import { QueryProvider } from '@/components/providers/query-provider'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
title: {
default: 'Next.js 15 TypeScript Blog',
template: '%s | Next.js 15 TypeScript Blog',
},
description: 'A modern full-stack blog built with Next.js 15, TypeScript, and Prisma.',
keywords: [
'Next.js',
'TypeScript',
'React',
'Tailwind CSS',
'Prisma',
'Blog',
'Full-stack',
],
authors: [
{
name: 'Your Name',
url: 'https://yourwebsite.com',
},
],
creator: 'Your Name',
openGraph: {
type: 'website',
locale: 'en_US',
url: process.env.NEXT_PUBLIC_APP_URL,
siteName: 'Next.js 15 TypeScript Blog',
title: 'Next.js 15 TypeScript Blog',
description: 'A modern full-stack blog built with Next.js 15, TypeScript, and Prisma.',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Next.js 15 TypeScript Blog',
},
],
},
twitter: {
card: 'summary_large_image',
creator: '@yourusername',
title: 'Next.js 15 TypeScript Blog',
description: 'A modern full-stack blog built with Next.js 15, TypeScript, and Prisma.',
images: ['/og-image.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
manifest: '/manifest.json',
icons: {
icon: '/favicon.ico',
shortcut: '/favicon-16x16.png',
apple: '/apple-touch-icon.png',
},
}
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: 'white' },
{ media: '(prefers-color-scheme: dark)', color: 'black' },
],
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
}
interface RootLayoutProps {
children: React.ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<body className={cn('min-h-screen bg-background font-sans antialiased', inter.variable)}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<AuthProvider>
<div className="relative flex min-h-screen flex-col">
{children}
</div>
<Toaster />
</AuthProvider>
</QueryProvider>
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
)
}
src/app/layout.tsx
import { Header } from '@/components/layout/header'
import { Footer } from '@/components/layout/footer'
import { Sidebar } from '@/components/layout/sidebar'
interface MainLayoutProps {
children: React.ReactNode
}
export default function MainLayout({ children }: MainLayoutProps) {
return (
<>
<Header />
<div className="flex-1">
<div className="container mx-auto px-4 py-8">
<div className="flex gap-8">
<aside className="w-64 shrink-0">
<Sidebar />
</aside>
<main className="flex-1 min-w-0">
{children}
</main>
</div>
</div>
</div>
<Footer />
</>
)
}
src/app/(main)/layout.tsx
Step 4: Server Components and Data Fetching
Create efficient Server Components with proper data fetching:
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { PostCard } from '@/components/posts/post-card'
import { PostCardSkeleton } from '@/components/posts/post-card-skeleton'
import { CategoryFilter } from '@/components/posts/category-filter'
import { SearchForm } from '@/components/search/search-form'
import { Pagination } from '@/components/ui/pagination'
import { getFeaturedPosts, getRecentPosts, getCategories } from '@/lib/posts'
import { SearchParamsType } from '@/types'
export const metadata: Metadata = {
title: 'Home',
description: 'Discover the latest blog posts and articles.',
}
interface HomePageProps {
searchParams: SearchParamsType
}
// Server Component for featured posts
async function FeaturedPosts() {
const posts = await getFeaturedPosts({ limit: 6 })
if (posts.length === 0) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
No featured posts yet
</h3>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Check back later for featured content.
</p>
</div>
)
}
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} featured />
))}
</div>
)
}
// Server Component for recent posts
async function RecentPosts({ searchParams }: { searchParams: SearchParamsType }) {
const page = Number(searchParams.page) || 1
const category = searchParams.category as string
const search = searchParams.q as string
const { data: posts, pagination } = await getRecentPosts({
page,
limit: 12,
category,
search,
})
if (posts.length === 0) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
No posts found
</h3>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Try adjusting your search or filter criteria.
</p>
</div>
)
}
return (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
{pagination.pages > 1 && (
<div className="mt-12 flex justify-center">
<Pagination
currentPage={pagination.page}
totalPages={pagination.pages}
baseUrl="/"
searchParams={searchParams}
/>
</div>
)}
</>
)
}
// Server Component for categories
async function Categories() {
const categories = await getCategories()
return <CategoryFilter categories={categories} />
}
export default function HomePage({ searchParams }: HomePageProps) {
return (
<div className="space-y-12">
{/* Hero Section */}
<section className="text-center py-12 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg">
<h1 className="text-4xl font-bold mb-4">
Welcome to Our Blog
</h1>
<p className="text-xl mb-8 opacity-90">
Discover amazing content and join our community
</p>
<SearchForm />
</section>
{/* Categories Filter */}
<section>
<Suspense fallback={<div className="h-12 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />}>
<Categories />
</Suspense>
</section>
{/* Featured Posts */}
{!searchParams.q && !searchParams.category && (
<section>
<h2 className="text-2xl font-bold mb-6">Featured Posts</h2>
<Suspense
fallback={
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
}
>
<FeaturedPosts />
</Suspense>
</section>
)}
{/* Recent Posts */}
<section>
<h2 className="text-2xl font-bold mb-6">
{searchParams.q || searchParams.category ? 'Search Results' : 'Recent Posts'}
</h2>
<Suspense
key={`${searchParams.page}-${searchParams.category}-${searchParams.q}`}
fallback={
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 12 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
}
>
<RecentPosts searchParams={searchParams} />
</Suspense>
</section>
</div>
)
}
// Generate static params for ISR
export async function generateStaticParams() {
return [
{ searchParams: {} }, // Home page
]
}
// Revalidation
export const revalidate = 3600 // 1 hour
src/app/(main)/page.tsx
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { CalendarIcon, UserIcon, EyeIcon, HeartIcon } from 'lucide-react'
import { getPostBySlug, getRelatedPosts, getPostViews } from '@/lib/posts'
import { formatDate } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { PostCard } from '@/components/posts/post-card'
import { CommentSection } from '@/components/comments/comment-section'
import { LikeButton } from '@/components/posts/like-button'
import { ShareButton } from '@/components/posts/share-button'
import { TableOfContents } from '@/components/posts/table-of-contents'
interface PostPageProps {
params: {
slug: string
}
}
// Generate metadata for SEO
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) {
return {
title: 'Post Not Found',
}
}
return {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
keywords: post.tags.map(tag => tag.name),
authors: [{ name: post.author.name }],
openGraph: {
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
type: 'article',
publishedTime: post.publishedAt?.toISOString(),
modifiedTime: post.updatedAt.toISOString(),
authors: [post.author.name],
images: post.featuredImage ? [
{
url: post.featuredImage,
width: 1200,
height: 630,
alt: post.title,
}
] : [],
tags: post.tags.map(tag => tag.name),
},
twitter: {
card: 'summary_large_image',
title: post.seoTitle || post.title,
description: post.seoDescription || post.excerpt,
images: post.featuredImage ? [post.featuredImage] : [],
},
alternates: {
canonical: `/posts/${post.slug}`,
},
}
}
// Server Component for post views
async function PostViews({ slug }: { slug: string }) {
const views = await getPostViews(slug)
return (
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<EyeIcon className="w-4 h-4" />
<span>{views.toLocaleString()} views</span>
</div>
)
}
// Server Component for related posts
async function RelatedPosts({ postId, categoryId }: { postId: string; categoryId: string }) {
const relatedPosts = await getRelatedPosts(postId, categoryId, 4)
if (relatedPosts.length === 0) {
return null
}
return (
<section className="mt-16">
<h2 className="text-2xl font-bold mb-6">Related Posts</h2>
<div className="grid gap-6 md:grid-cols-2">
{relatedPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
)
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPostBySlug(params.slug)
if (!post) {
notFound()
}
// Increment view count in background
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/posts/${post.id}/views`, {
method: 'POST',
cache: 'no-store',
}).catch(() => {}) // Silently fail
return (
<article className="max-w-4xl mx-auto">
{/* Article Header */}
<header className="mb-8">
{post.featuredImage && (
<div className="relative aspect-video mb-6 rounded-lg overflow-hidden">
<Image
src={post.featuredImage}
alt={post.title}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
/>
</div>
)}
<div className="flex flex-wrap gap-2 mb-4">
<Link href={`/categories/${post.category.slug}`}>
<Badge variant="secondary" className="hover:bg-primary hover:text-primary-foreground">
{post.category.name}
</Badge>
</Link>
{post.tags.map((tag) => (
<Link key={tag.id} href={`/tags/${tag.slug}`}>
<Badge variant="outline" className="hover:bg-muted">
{tag.name}
</Badge>
</Link>
))}
</div>
<h1 className="text-4xl font-bold mb-4 leading-tight">
{post.title}
</h1>
{post.excerpt && (
<p className="text-xl text-gray-600 dark:text-gray-400 mb-6">
{post.excerpt}
</p>
)}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400 mb-6">
<div className="flex items-center gap-2">
{post.author.avatar && (
<Image
src={post.author.avatar}
alt={post.author.name}
width={24}
height={24}
className="rounded-full"
/>
)}
<Link href={`/authors/${post.author.username}`} className="hover:text-primary">
{post.author.name}
</Link>
</div>
<div className="flex items-center gap-1">
<CalendarIcon className="w-4 h-4" />
<time dateTime={post.publishedAt?.toISOString()}>
{formatDate(post.publishedAt || post.createdAt)}
</time>
</div>
<Suspense fallback={<div className="w-20 h-5 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />}>
<PostViews slug={post.slug} />
</Suspense>
<div className="flex items-center gap-1">
<HeartIcon className="w-4 h-4" />
<span>{post.likeCount} likes</span>
</div>
</div>
<div className="flex items-center gap-4">
<LikeButton postId={post.id} initialLiked={false} initialCount={post.likeCount} />
<ShareButton url={`/posts/${post.slug}`} title={post.title} />
</div>
<Separator className="mt-6" />
</header>
{/* Article Content with Table of Contents */}
<div className="lg:grid lg:grid-cols-4 lg:gap-8">
<div className="lg:col-span-3">
<div
className="prose prose-gray dark:prose-invert max-w-none prose-headings:scroll-mt-20"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</div>
<aside className="lg:col-span-1">
<div className="sticky top-8 space-y-6">
<TableOfContents content={post.content} />
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<h3 className="font-semibold mb-2">About the Author</h3>
<div className="flex items-start gap-3">
{post.author.avatar && (
<Image
src={post.author.avatar}
alt={post.author.name}
width={48}
height={48}
className="rounded-full"
/>
)}
<div>
<Link href={`/authors/${post.author.username}`} className="font-medium hover:text-primary">
{post.author.name}
</Link>
{post.author.bio && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{post.author.bio}
</p>
)}
</div>
</div>
</div>
</div>
</aside>
</div>
{/* Comments Section */}
<section className="mt-16">
<Separator className="mb-8" />
<Suspense fallback={<div className="h-48 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />}>
<CommentSection postId={post.id} />
</Suspense>
</section>
{/* Related Posts */}
<Suspense fallback={<div className="mt-16 h-64 bg-gray-100 dark:bg-gray-800 rounded animate-pulse" />}>
<RelatedPosts postId={post.id} categoryId={post.categoryId} />
</Suspense>
</article>
)
}
// Generate static params for build time
export async function generateStaticParams() {
// In production, you might want to get all post slugs
// For now, return empty array to generate on-demand
return []
}
// Enable ISR with revalidation
export const revalidate = 3600 // 1 hour
src/app/(main)/posts/[slug]/page.tsx
Step 5: API Routes with App Router
Create comprehensive API routes using the new App Router API:
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ApiResponse, PaginatedResponse, Post } from "@/types";
// Validation schemas
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
excerpt: z.string().max(500).optional(),
categoryId: z.string(),
tagIds: z.array(z.string()).optional(),
featuredImage: z.string().url().optional(),
seoTitle: z.string().max(60).optional(),
seoDescription: z.string().max(160).optional(),
status: z.enum(["DRAFT", "PUBLISHED"]).default("DRAFT"),
});
const searchSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(10),
search: z.string().optional(),
category: z.string().optional(),
author: z.string().optional(),
status: z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]).optional(),
sortBy: z
.enum(["createdAt", "updatedAt", "publishedAt", "viewCount", "likeCount"])
.default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
// GET /api/posts - Get posts with pagination and filters
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const params = Object.fromEntries(searchParams.entries());
// Validate query parameters
const validatedParams = searchSchema.parse(params);
const { page, limit, search, category, author, status, sortBy, sortOrder } =
validatedParams;
// Build where clause
const where: any = {};
if (search) {
where.OR = [
{ title: { contains: search, mode: "insensitive" } },
{ content: { contains: search, mode: "insensitive" } },
{ excerpt: { contains: search, mode: "insensitive" } },
];
}
if (category) {
where.category = {
slug: category,
};
}
if (author) {
where.author = {
username: author,
};
}
if (status) {
where.status = status;
} else {
// Default to published posts for public API
where.status = "PUBLISHED";
}
// Calculate pagination
const skip = (page - 1) * limit;
// Get posts with count
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
author: {
select: {
id: true,
name: true,
username: true,
avatar: true,
},
},
category: {
select: {
id: true,
name: true,
slug: true,
color: true,
},
},
tags: {
select: {
id: true,
name: true,
slug: true,
color: true,
},
},
_count: {
select: {
comments: {
where: { status: "APPROVED" },
},
likes: true,
},
},
},
}),
prisma.post.count({ where }),
]);
// Transform data
const transformedPosts = posts.map(post => ({
...post,
commentCount: post._count.comments,
likeCount: post._count.likes,
_count: undefined,
}));
const response: ApiResponse<PaginatedResponse<Post>> = {
success: true,
data: {
data: transformedPosts as Post[],
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1,
},
},
};
return NextResponse.json(response);
} catch (error) {
console.error("GET /api/posts error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
message: "Invalid request parameters",
errors: error.flatten().fieldErrors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
message: "Internal server error",
},
{ status: 500 }
);
}
}
// POST /api/posts - Create a new post
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json(
{ success: false, message: "Unauthorized" },
{ status: 401 }
);
}
const body = await request.json();
const validatedData = createPostSchema.parse(body);
// Generate slug from title
const slug = validatedData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
// Check if slug already exists
const existingPost = await prisma.post.findUnique({
where: { slug },
});
if (existingPost) {
return NextResponse.json(
{
success: false,
message: "A post with this title already exists",
},
{ status: 409 }
);
}
// Verify category exists
const category = await prisma.category.findUnique({
where: { id: validatedData.categoryId },
});
if (!category) {
return NextResponse.json(
{
success: false,
message: "Category not found",
},
{ status: 400 }
);
}
// Verify tags exist (if provided)
if (validatedData.tagIds && validatedData.tagIds.length > 0) {
const existingTags = await prisma.tag.findMany({
where: { id: { in: validatedData.tagIds } },
});
if (existingTags.length !== validatedData.tagIds.length) {
return NextResponse.json(
{
success: false,
message: "One or more tags not found",
},
{ status: 400 }
);
}
}
// Create post
const post = await prisma.post.create({
data: {
title: validatedData.title,
content: validatedData.content,
excerpt: validatedData.excerpt,
slug,
status: validatedData.status,
featuredImage: validatedData.featuredImage,
seoTitle: validatedData.seoTitle,
seoDescription: validatedData.seoDescription,
publishedAt: validatedData.status === "PUBLISHED" ? new Date() : null,
authorId: session.user.id,
categoryId: validatedData.categoryId,
tags: validatedData.tagIds
? {
connect: validatedData.tagIds.map(id => ({ id })),
}
: undefined,
},
include: {
author: {
select: {
id: true,
name: true,
username: true,
avatar: true,
},
},
category: {
select: {
id: true,
name: true,
slug: true,
color: true,
},
},
tags: {
select: {
id: true,
name: true,
slug: true,
color: true,
},
},
},
});
const response: ApiResponse<Post> = {
success: true,
data: post as Post,
message: "Post created successfully",
};
return NextResponse.json(response, { status: 201 });
} catch (error) {
console.error("POST /api/posts error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
message: "Invalid request data",
errors: error.flatten().fieldErrors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
message: "Internal server error",
},
{ status: 500 }
);
}
}
src/app/api/posts/route.ts
Best Practices Summary
- App Router: Use the new App Router with Server Components by default
- Type Safety: Implement comprehensive TypeScript types throughout
- Performance: Leverage streaming, Suspense, and proper data fetching
- SEO: Generate proper metadata and structured data
- Caching: Use appropriate caching strategies (ISR, dynamic, static)
- Error Handling: Implement proper error boundaries and loading states
- Security: Use proper authentication and data validation
- Accessibility: Ensure components are accessible and semantic
- Testing: Write comprehensive tests for components and API routes
- Deployment: Use proper build optimization and deployment strategies
Development Commands
# Start development server
npm run dev
# Build for production
npm run build
# Start production server
npm start
# Run tests
npm test
# Run Storybook
npm run storybook
Your Next.js 15 TypeScript application is now ready for production with modern App Router features, Server Components, comprehensive type safety, and optimized performance!