Full-Stack Blog Tutorial¶
Complete tutorial for building a modern blog with Next.js App Router, Server Components, Server Actions, and Clean Architecture.
Overview¶
This tutorial demonstrates all modern Next.js 14+ features in a practical blog application:
| Feature | Technology |
|---|---|
| App Router | Next.js 14+ with app/ directory |
| Server Components | RSC for data fetching |
| Server Actions | "use server" for mutations |
| Client Components | Interactive UI with "use client" |
| Suspense & Streaming | Progressive rendering |
| Edge Runtime | Fast API routes |
| ISR | Incremental Static Regeneration |
| Clean Architecture | Domain/UseCase/Repository layers |
| State Management | Zustand + React Query |
Prerequisites¶
Project Structure¶
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── users/
│ │ └── register/
│ │ └── page.tsx
│ ├── blog/
│ │ ├── page.tsx
│ │ ├── new/
│ │ │ └── page.tsx
│ │ └── [userId]/
│ │ └── page.tsx
│ └── api/
│ └── posts/
│ └── route.ts
├── core/
│ ├── domain/
│ │ ├── user.ts
│ │ └── post.ts
│ ├── repository/
│ │ ├── userRepository.ts
│ │ └── postRepository.ts
│ └── usecases/
│ ├── userUseCase.ts
│ └── postUseCase.ts
├── components/
│ ├── PostList.tsx
│ └── PostItem.tsx
├── lib/
│ └── actions.ts
└── store/
└── uiStore.ts
Step 1: Domain Layer¶
Define core entities.
core/domain/user.ts¶
core/domain/post.ts¶
export interface Post {
id: number;
title: string;
content: string;
userId: number;
createdAt?: Date;
}
Step 2: Repository Layer¶
Handle data access (with mock implementation).
core/repository/userRepository.ts¶
import { User } from '../domain/user';
let users: User[] = [];
export const userRepository = {
async register(user: User): Promise<User> {
/* Real implementation would call API:
const res = await fetch("https://corporate-api.px-wing.com/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
return await res.json();
*/
users.push(user);
return user;
},
async getAll(): Promise<User[]> {
/* Real implementation:
const res = await fetch("https://corporate-api.px-wing.com/api/users");
return await res.json();
*/
return users;
},
async getById(id: number): Promise<User | undefined> {
return users.find((u) => u.id === id);
},
};
core/repository/postRepository.ts¶
import { Post } from '../domain/post';
let posts: Post[] = [
{
id: 1,
title: 'Welcome to Next.js Blog',
content: 'This is built with Server Components and Server Actions!',
userId: 1,
createdAt: new Date(),
},
{
id: 2,
title: 'Clean Architecture in Frontend',
content: 'Separating concerns makes code maintainable.',
userId: 1,
createdAt: new Date(),
},
];
export const postRepository = {
async getAll(): Promise<Post[]> {
/* Real implementation:
const res = await fetch("https://corporate-api.px-wing.com/api/blog");
return await res.json();
*/
return posts.sort((a, b) => (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0));
},
async getByUserId(userId: number): Promise<Post[]> {
return posts.filter((p) => p.userId === userId);
},
async create(post: Omit<Post, 'id' | 'createdAt'>): Promise<Post> {
/* Real implementation:
const res = await fetch("https://corporate-api.px-wing.com/api/blog", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(post),
});
return await res.json();
*/
const newPost: Post = {
...post,
id: Date.now(),
createdAt: new Date(),
};
posts.unshift(newPost);
return newPost;
},
async delete(id: number): Promise<void> {
/* Real implementation:
await fetch(`https://corporate-api.px-wing.com/api/blog/${id}`, {
method: "DELETE",
});
*/
posts = posts.filter((p) => p.id !== id);
},
};
Step 3: UseCase Layer¶
Business logic layer.
core/usecases/userUseCase.ts¶
import { userRepository } from '../repository/userRepository';
import { User } from '../domain/user';
export async function registerUser(name: string, email: string): Promise<User> {
// Add validation
if (!name || !email) {
throw new Error('Name and email are required');
}
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
const user: User = {
id: Date.now(),
name,
email,
};
return userRepository.register(user);
}
export async function getUsers(): Promise<User[]> {
return userRepository.getAll();
}
export async function getUserById(id: number): Promise<User | undefined> {
return userRepository.getById(id);
}
core/usecases/postUseCase.ts¶
import { postRepository } from '../repository/postRepository';
import { Post } from '../domain/post';
export async function getAllPosts(): Promise<Post[]> {
return postRepository.getAll();
}
export async function getPostsByUser(userId: number): Promise<Post[]> {
return postRepository.getByUserId(userId);
}
export async function createPost(
title: string,
content: string,
userId: number
): Promise<Post> {
// Validation
if (!title || !content) {
throw new Error('Title and content are required');
}
if (title.length < 3) {
throw new Error('Title must be at least 3 characters');
}
return postRepository.create({ title, content, userId });
}
export async function deletePost(id: number): Promise<void> {
return postRepository.delete(id);
}
Step 4: Server Actions¶
Bridge between UI and business logic.
lib/actions.ts¶
'use server';
import { revalidatePath } from 'next/cache';
import { registerUser } from '@/core/usecases/userUseCase';
import { createPost, deletePost } from '@/core/usecases/postUseCase';
export async function registerUserAction(prevState: any, formData: FormData) {
try {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
await registerUser(name, email);
return { ok: true, message: 'User registered successfully!' };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'Registration failed',
};
}
}
export async function createPostAction(prevState: any, formData: FormData) {
try {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const userId = Number(formData.get('userId'));
await createPost(title, content, userId);
// Revalidate the blog pages
revalidatePath('/blog');
revalidatePath(`/blog/${userId}`);
return { ok: true, message: 'Post created successfully!' };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : 'Failed to create post',
};
}
}
export async function deletePostAction(id: number) {
try {
await deletePost(id);
revalidatePath('/blog');
return { ok: true };
} catch (error) {
return { ok: false, message: 'Failed to delete post' };
}
}
Step 5: Server Components¶
Data fetching and rendering.
app/blog/page.tsx¶
import { Suspense } from 'react';
import { getAllPosts } from '@/core/usecases/postUseCase';
import PostList from '@/components/PostList';
import Link from 'next/link';
// Enable ISR - revalidate every 60 seconds
export const revalidate = 60;
export default async function BlogPage() {
const posts = await getAllPosts();
return (
<main className="max-w-4xl mx-auto p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">Blog Posts</h1>
<Link
href="/blog/new"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
+ New Post
</Link>
</div>
<Suspense fallback={<PostListSkeleton />}>
<PostList posts={posts} />
</Suspense>
</main>
);
}
function PostListSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="border p-4 rounded animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
))}
</div>
);
}
app/blog/[userId]/page.tsx¶
import { getPostsByUser } from '@/core/usecases/postUseCase';
import { getUserById } from '@/core/usecases/userUseCase';
import PostList from '@/components/PostList';
interface PageProps {
params: { userId: string };
}
export default async function UserBlogPage({ params }: PageProps) {
const userId = parseInt(params.userId);
const [posts, user] = await Promise.all([
getPostsByUser(userId),
getUserById(userId),
]);
return (
<main className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-2">
{user ? `${user.name}'s Posts` : 'User Posts'}
</h1>
{user && <p className="text-gray-600 mb-8">{user.email}</p>}
{posts.length === 0 ? (
<p className="text-gray-500">No posts yet.</p>
) : (
<PostList posts={posts} />
)}
</main>
);
}
Step 6: Client Components¶
Interactive UI elements.
components/PostItem.tsx¶
'use client';
import { useTransition } from 'react';
import { deletePostAction } from '@/lib/actions';
import type { Post } from '@/core/domain/post';
interface PostItemProps {
post: Post;
}
export default function PostItem({ post }: PostItemProps) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!confirm('Are you sure you want to delete this post?')) return;
startTransition(async () => {
await deletePostAction(post.id);
});
};
return (
<article className="border border-gray-200 rounded-lg p-6 hover:shadow-lg transition-shadow">
<h2 className="text-2xl font-bold mb-2">{post.title}</h2>
<p className="text-gray-700 mb-4">{post.content}</p>
<div className="flex justify-between items-center text-sm text-gray-500">
<span>User ID: {post.userId}</span>
<div className="flex gap-2">
{post.createdAt && (
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
)}
<button
onClick={handleDelete}
disabled={isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</article>
);
}
components/PostList.tsx¶
import type { Post } from '@/core/domain/post';
import PostItem from './PostItem';
interface PostListProps {
posts: Post[];
}
export default function PostList({ posts }: PostListProps) {
if (posts.length === 0) {
return <p className="text-gray-500">No posts found.</p>;
}
return (
<div className="space-y-6">
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</div>
);
}
app/blog/new/page.tsx¶
'use client';
import { useFormState } from 'react-dom';
import { createPostAction } from '@/lib/actions';
import Link from 'next/link';
export default function NewPostPage() {
const [state, formAction] = useFormState(createPostAction, {
ok: false,
message: '',
});
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Create New Post</h1>
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Enter post title"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-2">
Content
</label>
<textarea
id="content"
name="content"
required
rows={8}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Write your post content..."
/>
</div>
<div>
<label htmlFor="userId" className="block text-sm font-medium mb-2">
User ID
</label>
<input
type="number"
id="userId"
name="userId"
required
defaultValue={1}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Enter your user ID"
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 font-medium"
>
Create Post
</button>
<Link
href="/blog"
className="border border-gray-300 px-6 py-2 rounded-lg hover:bg-gray-50 font-medium"
>
Cancel
</Link>
</div>
{state.message && (
<div
className={`p-4 rounded-lg ${
state.ok
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{state.message}
</div>
)}
</form>
</main>
);
}
Step 7: User Registration¶
app/users/register/page.tsx¶
'use client';
import { useFormState } from 'react-dom';
import { registerUserAction } from '@/lib/actions';
import Link from 'next/link';
export default function RegisterPage() {
const [state, formAction] = useFormState(registerUserAction, {
ok: false,
message: '',
});
return (
<main className="max-w-md mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Register User</h1>
<form action={formAction} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="your.email@example.com"
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 font-medium"
>
Register
</button>
{state.message && (
<div
className={`p-4 rounded-lg ${
state.ok
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{state.message}
</div>
)}
<Link href="/blog" className="block text-center text-blue-500 hover:underline">
← Back to Blog
</Link>
</form>
</main>
);
}
Step 8: Edge Runtime API¶
app/api/posts/route.ts¶
import { NextResponse } from 'next/server';
import { getAllPosts } from '@/core/usecases/postUseCase';
export const runtime = 'edge';
export async function GET() {
try {
const posts = await getAllPosts();
return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}
Step 9: State Management (Optional)¶
store/uiStore.ts¶
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UiState {
theme: 'light' | 'dark';
sidebarOpen: boolean;
setTheme: (theme: 'light' | 'dark') => void;
toggleSidebar: () => void;
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
theme: 'light',
sidebarOpen: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{
name: 'ui-storage',
}
)
);
Step 10: Root Layout¶
app/layout.tsx¶
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Next.js Blog - Full Stack Tutorial',
description: 'Built with App Router, Server Components, and Server Actions',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<nav className="bg-gray-900 text-white p-4">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<a href="/" className="text-xl font-bold">
Next.js Blog
</a>
<div className="flex gap-4">
<a href="/blog" className="hover:text-gray-300">
Blog
</a>
<a href="/users/register" className="hover:text-gray-300">
Register
</a>
</div>
</div>
</nav>
{children}
</body>
</html>
);
}
app/page.tsx¶
import Link from 'next/link';
export default function HomePage() {
return (
<main className="max-w-4xl mx-auto p-8 text-center">
<h1 className="text-5xl font-bold mb-4">Welcome to Next.js Blog</h1>
<p className="text-xl text-gray-600 mb-8">
A full-stack blog built with Next.js App Router, Server Components, and Clean Architecture
</p>
<div className="flex gap-4 justify-center">
<Link
href="/blog"
className="bg-blue-500 text-white px-8 py-3 rounded-lg hover:bg-blue-600 font-medium"
>
View Blog
</Link>
<Link
href="/users/register"
className="border border-gray-300 px-8 py-3 rounded-lg hover:bg-gray-50 font-medium"
>
Register
</Link>
</div>
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-8 text-left">
<div className="border p-6 rounded-lg">
<h3 className="font-bold text-lg mb-2">Server Components</h3>
<p className="text-gray-600">
Direct data fetching and rendering on the server for optimal performance
</p>
</div>
<div className="border p-6 rounded-lg">
<h3 className="font-bold text-lg mb-2">Server Actions</h3>
<p className="text-gray-600">
Form submissions and mutations without API routes
</p>
</div>
<div className="border p-6 rounded-lg">
<h3 className="font-bold text-lg mb-2">Clean Architecture</h3>
<p className="text-gray-600">
Separated concerns with Domain, UseCase, and Repository layers
</p>
</div>
</div>
</main>
);
}
Running the Application¶
# Install dependencies
npm install
# Run development server
npm run dev
# Open browser
open http://localhost:3000
Key Concepts Demonstrated¶
1. Server Components (RSC)¶
- Default rendering mode in App Router
- Direct data access without API routes
- Automatic code splitting
2. Server Actions¶
"use server"directive- Direct server-side mutations
- Integrated with React forms
3. Clean Architecture¶
- Domain: Core entities (User, Post)
- Repository: Data access layer
- UseCase: Business logic
- UI: Presentation layer
4. ISR (Incremental Static Regeneration)¶
5. Streaming with Suspense¶
Next Steps¶
- Add Authentication: Integrate NextAuth.js
- Add Database: Replace mock data with Prisma
- Add Validation: Use Zod for type-safe validation
- Add Testing: Write tests with Vitest and Playwright
- Add Styling: Enhance with Tailwind CSS
- Deploy: Deploy to Vercel
Complete Features¶
- ✅ User registration
- ✅ Post creation and deletion
- ✅ Server-side rendering
- ✅ Client-side interactivity
- ✅ Clean architecture layers
- ✅ Type-safe TypeScript
- ✅ ISR caching
- ✅ Suspense boundaries
- ✅ Edge runtime API
- ✅ Form handling with Server Actions