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>© 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! 🚀