Next.jsNext.js 기초 · 8기초

Next.js 실전 프로젝트 — 풀스택 블로그 플랫폼

Next.js프로젝트풀스택블로그ServerActions

프로젝트 구조

app/
├── layout.tsx                  ← 루트 레이아웃
├── page.tsx                    ← 홈 (글 목록)
├── posts/
│   ├── page.tsx                ← 전체 포스트 목록
│   ├── [slug]/page.tsx         ← 포스트 상세
│   └── new/page.tsx            ← 새 글 작성
├── api/
│   └── auth/[...nextauth]/     ← NextAuth
├── actions/
│   └── post.ts                 ← Server Actions
└── components/
    ├── PostCard.tsx
    ├── PostEditor.tsx
    └── AuthButtons.tsx

데이터 모델

// types/post.ts
export interface Post {
    id: string;
    title: string;
    slug: string;
    content: string;
    excerpt: string;
    coverImage?: string;
    published: boolean;
    authorId: string;
    author: { name: string; image?: string };
    createdAt: Date;
    updatedAt: Date;
    viewCount: number;
    tags: string[];
}

홈 페이지 (SSG + ISR)

// app/page.tsx
import Image from "next/image";
import Link from "next/link";
import { getPosts } from "@/lib/posts";

export const revalidate = 3600;  // 1시간마다 재검증

export default async function HomePage() {
    const posts = await getPosts({ published: true, limit: 6 });

    return (
        <main>
            <h1 className="text-3xl font-bold mb-8">최신 포스트</h1>
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                {posts.map(post => (
                    <PostCard key={post.id} post={post} />
                ))}
            </div>
        </main>
    );
}

function PostCard({ post }: { post: Post }) {
    return (
        <article className="border rounded-lg overflow-hidden">
            {post.coverImage && (
                <div className="relative h-48">
                    <Image
                        src={post.coverImage}
                        alt={post.title}
                        fill
                        className="object-cover"
                    />
                </div>
            )}
            <div className="p-4">
                <h2 className="font-bold text-lg mb-2">
                    <Link href={`/posts/${post.slug}`}>{post.title}</Link>
                </h2>
                <p className="text-gray-600 text-sm">{post.excerpt}</p>
                <div className="flex gap-2 mt-3">
                    {post.tags.map(tag => (
                        <span key={tag} className="text-xs bg-gray-100 px-2 py-1 rounded">
                            {tag}
                        </span>
                    ))}
                </div>
            </div>
        </article>
    );
}

포스트 상세 페이지

// app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPost, incrementViewCount } from "@/lib/posts";

interface Props {
    params: { slug: string };
}

export async function generateStaticParams() {
    const posts = await getPosts({ published: true });
    return posts.map(p => ({ slug: p.slug }));
}

export async function generateMetadata({ params }: Props) {
    const post = await getPost(params.slug);
    if (!post) return {};
    return {
        title: post.title,
        description: post.excerpt,
        openGraph: { images: post.coverImage ? [post.coverImage] : [] },
    };
}

export default async function PostPage({ params }: Props) {
    const post = await getPost(params.slug);
    if (!post) notFound();

    await incrementViewCount(post.id);

    return (
        <article className="max-w-3xl mx-auto">
            <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
            <div className="flex items-center gap-3 mb-8 text-gray-500">
                {post.author.image && (
                    <Image src={post.author.image} alt="" width={32} height={32} className="rounded-full" />
                )}
                <span>{post.author.name}</span>
                <span>·</span>
                <span>{new Date(post.createdAt).toLocaleDateString("ko-KR")}</span>
                <span>·</span>
                <span>조회 {post.viewCount}</span>
            </div>
            <div className="prose" dangerouslySetInnerHTML={{ __html: post.content }} />
        </article>
    );
}

Server Actions: 글 작성

// app/actions/post.ts
"use server";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
    const session = await auth();
    if (!session?.user?.id) throw new Error("로그인 필요");

    const title = formData.get("title") as string;
    const content = formData.get("content") as string;
    const tags = (formData.get("tags") as string).split(",").map(t => t.trim());

    const slug = title.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");

    const post = await db.posts.create({
        title, content, slug, tags,
        excerpt: content.substring(0, 200),
        authorId: session.user.id,
        published: false,
    });

    revalidatePath("/posts");
    redirect(`/posts/${post.slug}`);
}
// app/posts/new/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { createPost } from "@/actions/post";

export default async function NewPostPage() {
    const session = await auth();
    if (!session) redirect("/login");

    return (
        <form action={createPost} className="max-w-2xl mx-auto p-4">
            <h1 className="text-2xl font-bold mb-6">새 글 작성</h1>
            <input name="title" placeholder="제목" className="w-full border rounded p-2 mb-4" required />
            <textarea name="content" rows={20} placeholder="내용 (마크다운)" className="w-full border rounded p-2 mb-4" required />
            <input name="tags" placeholder="태그 (쉼표로 구분)" className="w-full border rounded p-2 mb-4" />
            <button type="submit" className="bg-blue-500 text-white px-6 py-2 rounded">
                발행
            </button>
        </form>
    );
}

학습 정리

Next.js 기초 시리즈에서 배운 내용:

주제
1편Next.js와 App Router 소개
2편라우팅, 레이아웃, Link
3편서버 컴포넌트 데이터 패칭
4편Server Actions, API Routes
5편이미지·폰트 최적화
6편NextAuth.js 인증
7편환경 변수와 Vercel 배포
8편실전 프로젝트

다음은 SQL 기초 — 데이터베이스를 다루는 필수 언어를 배웁니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.