Skip to content
Go back

Next.js 15 TypeScript: Full-Stack Web Application Development

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?

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 hoursrc/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 hoursrc/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

  1. App Router: Use the new App Router with Server Components by default
  2. Type Safety: Implement comprehensive TypeScript types throughout
  3. Performance: Leverage streaming, Suspense, and proper data fetching
  4. SEO: Generate proper metadata and structured data
  5. Caching: Use appropriate caching strategies (ISR, dynamic, static)
  6. Error Handling: Implement proper error boundaries and loading states
  7. Security: Use proper authentication and data validation
  8. Accessibility: Ensure components are accessible and semantic
  9. Testing: Write comprehensive tests for components and API routes
  10. 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!


Share this post on:

Previous Post
Prisma TypeScript: Modern Database ORM Development
Next Post
GraphQL TypeScript: API Development and Code Generation