Next.jsNext.js 기초강의 8개 중 8번째난이도 기초

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

발행 읽는 시간 약 1 분

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

강의 소개

Next.js 기초 시리즈 총정리 프로젝트입니다. App Router, Server Actions, NextAuth, 이미지 최적화를 모두 활용한 풀스택 블로그를 구현합니다.

프로젝트 구조#

Text
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

데이터 모델#

TypeScript
// 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)#

TSX
// 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>
    );
}

포스트 상세 페이지#

TSX
// 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: 글 작성#

TypeScript
// 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}`);
}
TSX
// 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 기초 — 데이터베이스를 다루는 필수 언어를 배웁니다.

Follow · Ask · Work

이 글이 도움이 됐다면

짧은 작업 기록과 데모는 인스타그램에 올립니다. 만들고 싶은 것이 있다면 프로젝트 의뢰나 협업 제안도 편하게 보내 주세요.

글쓴이

파란여우

직접 만든 AI 앱·제품의 개발·배포를 공개하는 실전형 테크 크리에이터

파란여우 소개 Instagram @bluefox._.hi

이어서 보기

만들고 싶은 것이 있다면 프로젝트 의뢰로 알려 주세요.