コンテンツにスキップ

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

npm install @tanstack/react-query zustand

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

export interface User {
  id: number;
  name: string;
  email: string;
}

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)

export const revalidate = 60; // Revalidate every 60 seconds

5. Streaming with Suspense

<Suspense fallback={<Skeleton />}>
  <SlowComponent />
</Suspense>

Next Steps

  1. Add Authentication: Integrate NextAuth.js
  2. Add Database: Replace mock data with Prisma
  3. Add Validation: Use Zod for type-safe validation
  4. Add Testing: Write tests with Vitest and Playwright
  5. Add Styling: Enhance with Tailwind CSS
  6. 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

Resources