SHIN
Next.js App Router로 풀스택 블로그 만들기
9편app/api/
├── posts/
│ ├── route.ts GET(목록), POST(생성)
│ └── [id]/
│ └── route.ts GET, PUT(수정), DELETE
└── upload/
└── route.ts POST(이미지 업로드)// src/app/api/posts/route.ts
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
import { calcReadingTime } from '@/lib/utils'
export async function POST(req: Request) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const { title, slug, content, excerpt, coverImage,
categoryId, tagIds, seriesId, seriesOrder,
status, featured, pinned, scheduledAt } = body
const post = await prisma.post.create({
data: {
title,
slug,
content,
excerpt,
coverImage,
status,
featured: featured ?? false,
pinned: pinned ?? false,
readingTime: calcReadingTime(content),
publishedAt: status === 'PUBLISHED' ? new Date() : null,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
authorId: session.user!.id!,
categoryId: categoryId || null,
tags: tagIds?.length
? { create: tagIds.map((tagId: string) => ({ tagId })) }
: undefined,
},
})
// 시리즈 연결
if (seriesId) {
await prisma.seriesPost.create({
data: { seriesId, postId: post.id, order: seriesOrder ?? 0 },
})
}
return NextResponse.json(post, { status: 201 })
}export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s가-힣]/g, '') // 특수문자 제거
.replace(/[\s_]+/g, '-') // 공백/언더스코어 → 하이픈
.replace(/^-+|-+$/g, '') // 앞뒤 하이픈 제거
.substring(0, 100)
}'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function PostEditor({ post, categories, tags, seriesList }) {
const [title, setTitle] = useState(post?.title ?? '')
const [content, setContent] = useState(post?.content ?? '')
const [status, setStatus] = useState(post?.status ?? 'DRAFT')
const router = useRouter()
const save = async (nextStatus = status) => {
const url = post ? `/api/posts/${post.id}` : '/api/posts'
const method = post ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, status: nextStatus, /* ...나머지 필드 */ }),
})
if (res.ok) router.push('/admin/posts')
}
return (
<div className="grid grid-cols-3 gap-6">
{/* 에디터 영역 */}
<div className="col-span-2 space-y-4">
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="포스트 제목"
className="text-2xl font-bold w-full bg-transparent border-b border-gray-700 pb-2"
/>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
placeholder="마크다운으로 작성하세요..."
className="w-full h-[60vh] font-mono text-sm resize-none"
/>
</div>
{/* 사이드 패널 */}
<aside className="space-y-6">
{/* 발행 상태 */}
<select value={status} onChange={e => setStatus(e.target.value)}>
<option value="DRAFT">초안</option>
<option value="PUBLISHED">발행</option>
<option value="ARCHIVED">보관</option>
</select>
{/* 저장 버튼 */}
<div className="flex gap-2">
<button onClick={() => save('DRAFT')}>임시저장</button>
<button onClick={() => save('PUBLISHED')}>발행</button>
</div>
</aside>
</div>
)
}// src/app/api/upload/route.ts
import { writeFile } from 'fs/promises'
import path from 'path'
import { auth } from '@/lib/auth'
export async function POST(req: Request) {
const session = await auth()
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) return Response.json({ error: 'No file' }, { status: 400 })
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const ext = file.name.split('.').pop()
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
const filepath = path.join(process.cwd(), 'public/uploads', filename)
await writeFile(filepath, buffer)
return Response.json({ url: `/uploads/${filename}` })
}const [posts, setPosts] = useState(initialPosts)
const deletePost = async (id: string) => {
// UI에서 즉시 제거
setPosts(prev => prev.filter(p => p.id !== id))
const res = await fetch(`/api/posts/${id}`, { method: 'DELETE' })
if (!res.ok) {
// 실패 시 롤백
setPosts(initialPosts)
alert('삭제에 실패했습니다.')
}
}다음 편에서는 블로그 프론트엔드와 마크다운 렌더링을 구현합니다.