Kembali ke Blog
Next.js 14: Complete Guide untuk Full-Stack Development 2025
Next.jsReactFull-StackSSRApp Router

Next.js 14: Complete Guide untuk Full-Stack Development 2025

Artikel ini sepenuhnya ditulis oleh teknologi AI ( Claude Sonnet 4 ) dan direview Oleh Muhammad Aji Sukma

Next.js 14 telah mengubah cara kita membangun aplikasi web full-stack dengan React. Dengan App Router yang matang, Server Components, dan Server Actions, Next.js kini menjadi framework yang sangat powerful untuk modern web development. Mari kita explore fitur-fitur terbaru dan best practices untuk 2025.

🎯 Mengapa Next.js 14 adalah Game Changer?

Fitur Utama Next.js 14

1. App Router yang Stabil App Router memberikan routing yang lebih intuitive dengan file-system based routing yang powerful.

// app/layout.tsx - Root Layout
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "My Blog",
  description: "Modern blog built with Next.js 14",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>
        <header className="bg-white shadow-sm border-b">
          <nav className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
            <div className="flex items-center justify-between h-16">
              <div className="flex items-center">
                <Link href="/" className="text-2xl font-bold text-gray-900">
                  MyBlog
                </Link>
              </div>
              <div className="flex items-center space-x-4">
                <Link href="/blog" className="text-gray-600 hover:text-gray-900">
                  Blog
                </Link>
                <Link href="/about" className="text-gray-600 hover:text-gray-900">
                  About
                </Link>
              </div>
            </div>
          </nav>
        </header>
        <main className="min-h-screen bg-gray-50">{children}</main>
        <footer className="bg-gray-900 text-white py-8">
          <div className="max-w-6xl mx-auto px-4 text-center">
            <p>&copy; 2025 MyBlog. All rights reserved.</p>
          </div>
        </footer>
      </body>
    </html>
  );
}

2. Server Components by Default Komponen server memungkinkan rendering di server untuk performance yang lebih baik.

// app/blog/page.tsx - Server Component
import { Suspense } from "react";
import BlogCard from "@/components/BlogCard";
import LoadingSkeleton from "@/components/LoadingSkeleton";

// Fetch data di server component
async function getBlogPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 60 }, // ISR with 60s revalidation
  });

  if (!res.ok) {
    throw new Error("Failed to fetch posts");
  }

  return res.json();
}

export default async function BlogPage() {
  const posts = await getBlogPosts();

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">Latest Blog Posts</h1>
        <p className="text-xl text-gray-600">
          Discover insights about web development and technology
        </p>
      </div>

      <Suspense fallback={<LoadingSkeleton />}>
        <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
          {posts.map((post) => (
            <BlogCard key={post.id} post={post} />
          ))}
        </div>
      </Suspense>
    </div>
  );
}

// Generate static params untuk dynamic routes
export async function generateStaticParams() {
  const posts = await getBlogPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// SEO Metadata
export async function generateMetadata({ params }) {
  const post = await getBlogPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.featuredImage],
    },
  };
}

3. Server Actions untuk Interaktivitas Server Actions memungkinkan server-side logic tanpa API routes terpisah.

// app/blog/new/page.tsx
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

// Server Action
async function createPost(formData) {
  "use server";

  const title = formData.get("title");
  const content = formData.get("content");
  const authorId = formData.get("authorId");

  // Validation
  if (!title || !content || !authorId) {
    throw new Error("All fields are required");
  }

  // Create post dalam database
  const post = await db.post.create({
    data: {
      title,
      content,
      authorId: parseInt(authorId),
      slug: generateSlug(title),
      published: false,
    },
  });

  // Revalidate cache
  revalidatePath("/blog");

  // Redirect ke post baru
  redirect(`/blog/${post.slug}`);
}

export default function NewPostPage() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Create New Post</h1>

      <form action={createPost} className="space-y-6">
        <div>
          <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
            Title
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <div>
          <label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-2">
            Content
          </label>
          <textarea
            id="content"
            name="content"
            rows={10}
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <input type="hidden" name="authorId" value="1" />

        <button
          type="submit"
          className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
        >
          Create Post
        </button>
      </form>
    </div>
  );
}

🏗️ App Router Architecture

File-System Based Routing

app/
├── layout.tsx          # Root layout (applies to all routes)
├── page.tsx           # Homepage (/)
├── loading.tsx        # Loading UI
├── error.tsx          # Error UI
├── not-found.tsx      # 404 page
├── globals.css        # Global styles
├── blog/
│   ├── layout.tsx     # Blog layout (/blog/*)
│   ├── page.tsx       # Blog listing (/blog)
│   ├── loading.tsx    # Loading for blog routes
│   ├── [slug]/
│   │   ├── page.tsx   # Blog post detail (/blog/[slug])
│   │   └── loading.tsx
│   └── new/
│       └── page.tsx   # Create post (/blog/new)
├── api/
│   ├── posts/
│   │   ├── route.ts   # /api/posts
│   │   └── [id]/
│   │       └── route.ts # /api/posts/[id]
│   └── auth/
│       └── route.ts   # /api/auth
└── (dashboard)/       # Route groups (doesn't affect URL)
    ├── layout.tsx     # Dashboard layout
    ├── analytics/
    │   └── page.tsx   # /analytics
    └── settings/
        └── page.tsx   # /settings

Dynamic Routes dengan TypeScript

// app/blog/[slug]/page.tsx
interface BlogPostPageProps {
  params: {
    slug: string;
  };
  searchParams: {
    [key: string]: string | string[] | undefined;
  };
}

export default async function BlogPostPage({ params, searchParams }: BlogPostPageProps) {
  const post = await getBlogPost(params.slug);

  if (!post) {
    notFound(); // Triggers not-found.tsx
  }

  return (
    <article className="max-w-4xl mx-auto px-4 py-8">
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
        <div className="flex items-center text-gray-600">
          <time dateTime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString()}</time>
          <span className="mx-2">•</span>
          <span>{post.readingTime} min read</span>
        </div>
      </header>

      <div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: post.content }} />

      <footer className="mt-12 pt-8 border-t">
        <div className="flex items-center">
          <img
            src={post.author.avatar}
            alt={post.author.name}
            className="w-12 h-12 rounded-full mr-4"
          />
          <div>
            <p className="font-medium text-gray-900">{post.author.name}</p>
            <p className="text-gray-600">{post.author.bio}</p>
          </div>
        </div>
      </footer>
    </article>
  );
}

// Type-safe params generation
export async function generateStaticParams(): Promise<{ slug: string }[]> {
  const posts = await getAllBlogPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

🔄 Data Fetching Strategies

Server-Side Rendering (SSR)

// app/dashboard/analytics/page.tsx
interface AnalyticsData {
  pageViews: number;
  uniqueVisitors: number;
  topPages: Array<{ path: string; views: number }>;
}

// Fetch fresh data setiap request
async function getAnalyticsData(): Promise<AnalyticsData> {
  const res = await fetch("https://analytics-api.example.com/data", {
    cache: "no-store", // Force dynamic rendering
  });

  return res.json();
}

export default async function AnalyticsPage() {
  const analytics = await getAnalyticsData();

  return (
    <div className="space-y-6">
      <h1 className="text-3xl font-bold">Analytics Dashboard</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-medium text-gray-900">Page Views</h3>
          <p className="text-3xl font-bold text-blue-600">{analytics.pageViews.toLocaleString()}</p>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-medium text-gray-900">Unique Visitors</h3>
          <p className="text-3xl font-bold text-green-600">
            {analytics.uniqueVisitors.toLocaleString()}
          </p>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-medium text-gray-900">Top Pages</h3>
          <ul className="mt-2 space-y-1">
            {analytics.topPages.slice(0, 3).map((page) => (
              <li key={page.path} className="flex justify-between">
                <span className="text-gray-600">{page.path}</span>
                <span className="font-medium">{page.views}</span>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </div>
  );
}

Static Site Generation (SSG) dengan Revalidation

// app/blog/page.tsx
async function getBlogPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: {
      revalidate: 3600, // Revalidate every hour
      tags: ["blog-posts"], // Cache tags untuk selective revalidation
    },
  });

  return res.json();
}

export default async function BlogPage() {
  const posts = await getBlogPosts();

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog Posts</h1>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <BlogCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

// Revalidate cache secara manual menggunakan Server Actions
async function revalidateBlogPosts() {
  "use server";

  revalidateTag("blog-posts");
}

Client-Side Data Fetching dengan SWR

// components/CommentsSection.tsx
"use client";

import useSWR from "swr";
import { useState } from "react";

interface Comment {
  id: number;
  author: string;
  content: string;
  createdAt: string;
}

const fetcher = (url: string) => fetch(url).then((res) => res.json());

interface CommentsSectionProps {
  postId: string;
}

export default function CommentsSection({ postId }: CommentsSectionProps) {
  const [newComment, setNewComment] = useState("");
  const {
    data: comments,
    error,
    mutate,
  } = useSWR<Comment[]>(`/api/posts/${postId}/comments`, fetcher, {
    refreshInterval: 30000, // Refresh every 30 seconds
    revalidateOnFocus: false,
  });

  const submitComment = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!newComment.trim()) return;

    try {
      const response = await fetch(`/api/posts/${postId}/comments`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ content: newComment }),
      });

      if (response.ok) {
        setNewComment("");
        mutate(); // Revalidate SWR cache
      }
    } catch (error) {
      console.error("Failed to post comment:", error);
    }
  };

  if (error) return <div className="text-red-600">Failed to load comments</div>;
  if (!comments) return <div className="animate-pulse">Loading comments...</div>;

  return (
    <section className="mt-12">
      <h3 className="text-2xl font-bold mb-6">Comments ({comments.length})</h3>

      <form onSubmit={submitComment} className="mb-8">
        <textarea
          value={newComment}
          onChange={(e) => setNewComment(e.target.value)}
          placeholder="Write a comment..."
          className="w-full p-3 border border-gray-300 rounded-lg resize-none"
          rows={3}
        />
        <button
          type="submit"
          disabled={!newComment.trim()}
          className="mt-2 bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
        >
          Post Comment
        </button>
      </form>

      <div className="space-y-4">
        {comments.map((comment) => (
          <div key={comment.id} className="bg-gray-50 p-4 rounded-lg">
            <div className="flex items-center justify-between mb-2">
              <span className="font-medium text-gray-900">{comment.author}</span>
              <time className="text-sm text-gray-500">
                {new Date(comment.createdAt).toLocaleDateString()}
              </time>
            </div>
            <p className="text-gray-700">{comment.content}</p>
          </div>
        ))}
      </div>
    </section>
  );
}

🔧 API Routes dengan Route Handlers

CRUD Operations

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";

// Validation schema
const CreatePostSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string().min(10),
  excerpt: z.string().optional(),
  published: z.boolean().default(false),
});

// GET /api/posts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");
  const search = searchParams.get("search") || "";

  try {
    const posts = await db.post.findMany({
      where: {
        published: true,
        ...(search && {
          OR: [
            { title: { contains: search, mode: "insensitive" } },
            { content: { contains: search, mode: "insensitive" } },
          ],
        }),
      },
      include: {
        author: {
          select: { id: true, name: true, email: true },
        },
        _count: {
          select: { comments: true, likes: true },
        },
      },
      orderBy: { createdAt: "desc" },
      skip: (page - 1) * limit,
      take: limit,
    });

    const total = await db.post.count({
      where: { published: true },
    });

    return NextResponse.json({
      posts,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit),
      },
    });
  } catch (error) {
    console.error("Failed to fetch posts:", error);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

// POST /api/posts
export async function POST(request: NextRequest) {
  try {
    // Check authentication
    const session = await auth(request);
    if (!session?.user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = await request.json();
    const validation = CreatePostSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json(
        { error: "Validation failed", issues: validation.error.issues },
        { status: 400 }
      );
    }

    const { title, content, excerpt, published } = validation.data;

    const post = await db.post.create({
      data: {
        title,
        content,
        excerpt: excerpt || content.substring(0, 200),
        published,
        slug: generateSlug(title),
        authorId: session.user.id,
      },
      include: {
        author: {
          select: { id: true, name: true, email: true },
        },
      },
    });

    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    console.error("Failed to create post:", error);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

Dynamic Route Handlers

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

interface RouteContext {
  params: {
    id: string;
  };
}

// GET /api/posts/[id]
export async function GET(request: NextRequest, { params }: RouteContext) {
  try {
    const post = await db.post.findUnique({
      where: { id: parseInt(params.id) },
      include: {
        author: {
          select: { id: true, name: true, email: true, avatar: true },
        },
        comments: {
          include: {
            author: {
              select: { id: true, name: true, avatar: true },
            },
          },
          orderBy: { createdAt: "desc" },
        },
        _count: {
          select: { likes: true, views: true },
        },
      },
    });

    if (!post) {
      return NextResponse.json({ error: "Post not found" }, { status: 404 });
    }

    // Increment view count
    await db.post.update({
      where: { id: parseInt(params.id) },
      data: { views: { increment: 1 } },
    });

    return NextResponse.json(post);
  } catch (error) {
    console.error("Failed to fetch post:", error);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

// PUT /api/posts/[id]
export async function PUT(request: NextRequest, { params }: RouteContext) {
  try {
    const session = await auth(request);
    if (!session?.user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const post = await db.post.findUnique({
      where: { id: parseInt(params.id) },
    });

    if (!post) {
      return NextResponse.json({ error: "Post not found" }, { status: 404 });
    }

    // Check ownership
    if (post.authorId !== session.user.id) {
      return NextResponse.json({ error: "Forbidden" }, { status: 403 });
    }

    const body = await request.json();
    const validation = CreatePostSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json(
        { error: "Validation failed", issues: validation.error.issues },
        { status: 400 }
      );
    }

    const updatedPost = await db.post.update({
      where: { id: parseInt(params.id) },
      data: validation.data,
      include: {
        author: {
          select: { id: true, name: true, email: true },
        },
      },
    });

    return NextResponse.json(updatedPost);
  } catch (error) {
    console.error("Failed to update post:", error);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }
}

🎨 Styling dengan Tailwind CSS

Global Styles dan Custom Components

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    font-feature-settings: "rlig" 1, "calt" 1;
  }
}

@layer components {
  .btn-primary {
    @apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors;
  }

  .btn-secondary {
    @apply bg-gray-200 hover:bg-gray-300 text-gray-900 font-medium py-2 px-4 rounded-md transition-colors;
  }

  .card {
    @apply bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow;
  }

  .prose-custom {
    @apply prose prose-gray max-w-none;
    @apply prose-headings:text-gray-900 prose-headings:font-bold;
    @apply prose-p:text-gray-700 prose-p:leading-relaxed;
    @apply prose-a:text-blue-600 hover:prose-a:text-blue-800;
    @apply prose-code:bg-gray-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded;
    @apply prose-pre:bg-gray-900 prose-pre:text-gray-100;
  }
}

@layer utilities {
  .text-gradient {
    @apply bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent;
  }

  .animate-fade-in {
    animation: fadeIn 0.5s ease-in-out;
  }

  @keyframes fadeIn {
    from {
      opacity: 0;
      transform: translateY(10px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }
}

⚡ Performance Optimization

Image Optimization

// components/OptimizedBlogCard.tsx
import Image from "next/image";
import Link from "next/link";

interface BlogCardProps {
  post: {
    id: number;
    title: string;
    excerpt: string;
    slug: string;
    featuredImage?: string;
    author: {
      name: string;
      avatar?: string;
    };
    publishedAt: string;
    readingTime: number;
  };
}

export default function BlogCard({ post }: BlogCardProps) {
  return (
    <article className="group bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-all duration-300">
      {post.featuredImage && (
        <div className="relative h-48 overflow-hidden">
          <Image
            src={post.featuredImage}
            alt={post.title}
            fill
            className="object-cover group-hover:scale-105 transition-transform duration-300"
            sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
            priority={false}
          />
        </div>
      )}

      <div className="p-6">
        <h3 className="text-xl font-bold mb-2 group-hover:text-blue-600 transition-colors">
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </h3>

        <p className="text-gray-600 mb-4 line-clamp-3">{post.excerpt}</p>

        <div className="flex items-center justify-between">
          <div className="flex items-center">
            {post.author.avatar && (
              <Image
                src={post.author.avatar}
                alt={post.author.name}
                width={32}
                height={32}
                className="rounded-full mr-3"
              />
            )}
            <div>
              <p className="text-sm font-medium text-gray-900">{post.author.name}</p>
              <p className="text-xs text-gray-500">
                {new Date(post.publishedAt).toLocaleDateString()}
              </p>
            </div>
          </div>

          <span className="text-xs text-gray-500">{post.readingTime} min read</span>
        </div>
      </div>
    </article>
  );
}

Bundle Analysis & Code Splitting

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
  },
  images: {
    domains: ["example.com", "images.unsplash.com"],
    formats: ["image/webp", "image/avif"],
  },
  // Bundle analyzer
  ...(process.env.ANALYZE === "true" && {
    webpack: (config, { isServer }) => {
      if (!isServer) {
        const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");
        config.plugins.push(
          new BundleAnalyzerPlugin({
            analyzerMode: "server",
            openAnalyzer: true,
          })
        );
      }
      return config;
    },
  }),
};

module.exports = nextConfig;

// Dynamic imports untuk code splitting
// components/ChartSection.tsx
import dynamic from "next/dynamic";
import { Suspense } from "react";

const Chart = dynamic(() => import("./Chart"), {
  loading: () => <div className="h-64 bg-gray-200 animate-pulse rounded" />,
  ssr: false, // Only render on client side
});

export default function ChartSection({ data }) {
  return (
    <div className="mt-8">
      <h2 className="text-2xl font-bold mb-4">Analytics Chart</h2>
      <Suspense fallback={<div>Loading chart...</div>}>
        <Chart data={data} />
      </Suspense>
    </div>
  );
}

🌟 Kesimpulan

Next.js 14 dengan App Router memberikan developer experience yang luar biasa untuk membangun aplikasi web modern. Dengan fitur-fitur seperti:

Core Features:

  • App Router dengan file-system based routing
  • Server Components untuk performance optimal
  • Server Actions untuk server-side interactivity
  • Streaming dengan Suspense boundaries

Performance:

  • Automatic Code Splitting dan lazy loading
  • Image Optimization dengan Next.js Image
  • Font Optimization dengan next/font
  • Bundle Analysis dan monitoring

Developer Experience:

  • TypeScript Support yang excellent
  • Hot Reload dan Fast Refresh
  • Built-in CSS Support dengan Tailwind CSS
  • API Routes yang powerful

Deployment:

  • Vercel Integration yang seamless
  • Static Export untuk hosting apapun
  • Edge Runtime untuk global performance
  • ISR untuk optimal caching

Next.js 14 adalah pilihan tepat untuk project full-stack di 2025, baik untuk website sederhana hingga aplikasi enterprise yang complex.

Pro Tips:

  • Gunakan Server Components sebanyak mungkin untuk performance
  • Implement proper error boundaries dan loading states
  • Leverage ISR untuk balance antara static dan dynamic content
  • Monitor bundle size dan optimize dengan dynamic imports
  • Use TypeScript untuk better developer experience

Happy coding with Next.js! 🚀