프로젝트 구조
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 기초 — 데이터베이스를 다루는 필수 언어를 배웁니다.