Next.js 15 κΈ°λ°μ λͺ¨λν ν¬νΈν΄λ¦¬μ€ μΉμ¬μ΄νΈ
GitHub κ³μ μ΄ μλ μ¬μ©μλ λκΈμ μμ±ν μ μλ νμ΄λΈλ¦¬λ λκΈ μμ€ν κ³Ό Notion CMSλ₯Ό νμ©ν λμ μ½ν μΈ κ΄λ¦¬ κΈ°λ₯μ μ 곡ν©λλ€.
- GSAP κΈ°λ° νμ΄ν μ λλ©μ΄μ
- μ€ν¬λ‘€ νΈλ¦¬κ±° μ λλ©μ΄μ
- λ°μν Hero Section
- κΈ°μ μ€ν μκ°ν
- ν¬νΈν΄λ¦¬μ€/λΈλ‘κ·Έ 미리보기
- Notion Database μ°λ (μ€μκ° λκΈ°ν)
- κ³ κΈ νν°λ§ μμ€ν (κΈ°μ μ€νλ³, κ²μμ΄)
- μΈν°λν°λΈ μΉ΄λ UI (3D νΈλ² ν¨κ³Ό)
- λμ μμΈ νμ΄μ§ (Notion λΈλ‘ λ λλ§)
- GitHub/Live λ§ν¬ μ°λ
- Notion CMS κΈ°λ° μ½ν μΈ κ΄λ¦¬
- μ€μκ° κ²μ (λλ°μ΄μ€ μ μ©)
- μΉ΄ν κ³ λ¦¬/νκ·Έ νν°λ§
- μ€λ§νΈ νμ΄μ§λ€μ΄μ
- μΈν°λν°λΈ λͺ©μ°¨ (μ€ν¬λ‘€ μ€νμ΄)
- κ΄λ ¨ ν¬μ€νΈ μΆμ² (AI κΈ°λ°)
- μ½λ νμ΄λΌμ΄ν (Shiki)
- GitHub μ¬μ©μ: Giscus (GitHub Discussions)
- μΌλ° μ¬μ©μ: Supabase + μ΄λ©μΌ μΈμ¦
- μμ€ν μ ν UI (ν΅κ³ νμ)
- μ€μκ° μ’μμ/λ΅κΈ κΈ°λ₯
- μ€νΈ μ κ³ μμ€ν
- λ€ν¬λͺ¨λ μλ μ°λ
- λ€ν¬/λΌμ΄νΈ λͺ¨λ (μμ€ν ν λ§ κ°μ§)
- μ€λ¬΄μ€ μ€ν¬λ‘€ (Lenis)
- λ§μ΄ν¬λ‘ μΈν°λμ (Framer Motion)
- λ°μν λμμΈ (λͺ¨λ°μΌ μ°μ )
- μ κ·Όμ± μ§μ (WCAG 2.2 AA)
- Framework: Next.js 15.1.x (App Router, RSC)
- Language: TypeScript 5.x
- Styling: Tailwind CSS 3.x
- Animation: GSAP, Framer Motion, Lenis
- State: Zustand
- Forms: React Hook Form
- BaaS: Supabase (PostgreSQL)
- CMS: Notion API
- Auth: Row Level Security (RLS)
- Real-time: WebSocket subscriptions
- Platform: Vercel
- Package Manager: pnpm
- Linting: ESLint, Prettier
- Version Control: Git
git clone https://github.com/your-username/portfolio.git
cd portfoliopnpm installcp .env.example .env.localνμ νκ²½ λ³μ:
# Notion API
NOTION_API_KEY=your_notion_integration_token
NOTION_PORTFOLIO_DATABASE_ID=your_portfolio_database_id
NOTION_BLOG_DATABASE_ID=your_blog_database_id
# Supabase (λκΈ μμ€ν
)
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# Giscus (GitHub λκΈ)
NEXT_PUBLIC_GISCUS_REPO=your-username/your-repo
NEXT_PUBLIC_GISCUS_REPO_ID=your_repo_id
NEXT_PUBLIC_GISCUS_CATEGORY=Announcements
NEXT_PUBLIC_GISCUS_CATEGORY_ID=your_category_id# Supabase λκΈ μμ€ν
μ€ν€λ§ μ μ©
# Supabase Dashboard > SQL Editorμμ lib/supabase/schema.sql μ€νpnpm devπ http://localhost:3000μμ νμΈνμΈμ!
fullkeem_portfolio/
βββ app/ # Next.js App Router
β βββ (routes)/ # λΌμ°νΈ κ·Έλ£Ή
β β βββ portfolio/ # ν¬νΈν΄λ¦¬μ€ νμ΄μ§
β β βββ blog/ # λΈλ‘κ·Έ νμ΄μ§
β β βββ contact/ # μ°λ½μ² νμ΄μ§
β βββ api/ # API Routes
β β βββ comments/ # λκΈ API
β βββ globals.css # μ μ μ€νμΌ
β βββ layout.tsx # λ£¨νΈ λ μ΄μμ
β βββ page.tsx # ννμ΄μ§
βββ components/ # μ¬μ¬μ© μ»΄ν¬λνΈ
β βββ home/ # ννμ΄μ§ μΉμ
λ€
β βββ portfolio/ # ν¬νΈν΄λ¦¬μ€ μ»΄ν¬λνΈ
β βββ blog/ # λΈλ‘κ·Έ μ»΄ν¬λνΈ
β βββ common/ # κ³΅ν΅ μ»΄ν¬λνΈ
β βββ ui/ # UI κΈ°λ³Έ μ»΄ν¬λνΈ
βββ lib/ # μ νΈλ¦¬ν° & μ€μ
β βββ notion/ # Notion API ν΄λΌμ΄μΈνΈ
β βββ supabase/ # Supabase μ€μ & μ€ν€λ§
β βββ utils.ts # κ³΅ν΅ μ νΈλ¦¬ν°
βββ store/ # Zustand μν κ΄λ¦¬
βββ types/ # TypeScript νμ
μ μ
βββ hooks/ # 컀μ€ν
ν
βββ docs/ # νλ‘μ νΈ λ¬Έμ
// ν¬νΈν΄λ¦¬μ€ μλ λκΈ°ν
const portfolios = await notionClient.getPortfolios({
filter: { tech: 'React' },
sort: 'created_time',
});
// λΈλ‘κ·Έ ν¬μ€νΈ μ€μκ° μ
λ°μ΄νΈ
const posts = await notionClient.getBlogPosts({
category: 'Development',
published: true,
});// μμ€ν
μ ν κΈ°λ° λκΈ λ λλ§
<Comments slug="blog-post-1" title="ν¬μ€νΈ μ λͺ©">
{/* GitHub μ¬μ©μ: Giscus */}
{/* μΌλ° μ¬μ©μ: Supabase + μ΄λ©μΌ */}
</Comments>
// μ€μκ° λκΈ κ΅¬λ
const unsubscribe = commentService.subscribeToComments(
postSlug,
(payload) => setComments(payload.new)
);// GSAP νμ΄ν μ λλ©μ΄μ
gsap.to('.typing-text', {
text: 'Full-Stack Developer',
duration: 2,
ease: 'none',
});
// μ€ν¬λ‘€ νΈλ¦¬κ±° μ λλ©μ΄μ
ScrollTrigger.create({
trigger: '.portfolio-section',
start: 'top 80%',
animation: gsap.from('.portfolio-card', {
y: 100,
opacity: 0,
stagger: 0.2,
}),
});- Notion Databaseμ μ νμ΄μ§ μμ±
- νμ μμ± μ λ ₯ (μ λͺ©, κΈ°μ μ€ν, λ§ν¬ λ±)
- μλμΌλ‘ μ¬μ΄νΈμ λ°μ β¨
- Notionμμ μ νμ΄μ§ μμ±
published: true체ν¬λ°μ€ νμ±ν- μ€μκ°μΌλ‘ λΈλ‘κ·Έμ κ²μ π
-- λκΈ μΉμΈ (κ΄λ¦¬μ)
SELECT approve_comment('comment-uuid');
-- μ€νΈ λκΈ μμ
SELECT delete_comment('comment-uuid');
-- ν΅κ³ μ‘°ν
SELECT * FROM comment_stats WHERE post_slug = 'blog-post';- β‘ μ΄λ―Έμ§ μ΅μ ν: Next.js Image + WebP
- π μ½λ λΆν : Dynamic Imports
- π¦ λ²λ€ μ΅μ ν: Tree Shaking
- π― μΊμ± μ λ΅: ISR + SWR
- π ꡬ쑰νλ λ°μ΄ν°: JSON-LD
- π·οΈ λ©ν νκ·Έ: Open Graph, Twitter Card
- πΊοΈ μ¬μ΄νΈλ§΅: μλ μμ±
- π€ robots.txt: ν¬λ‘€λ§ μ΅μ ν
- βΏ WCAG 2.2 AA μ€μ
- β¨οΈ ν€λ³΄λ λ€λΉκ²μ΄μ μ§μ
- π± μ€ν¬λ¦° 리λ μ΅μ ν
- π¨ μμ λλΉ 4.5:1 μ΄μ
# ν
μ€νΈ νμ΄μ§ μ μ
http://localhost:3000/test-comments
# API ν
μ€νΈ
curl -X GET "http://localhost:3000/api/comments?slug=test-post"
curl -X POST "http://localhost:3000/api/comments" \
-H "Content-Type: application/json" \
-d '{"post_slug":"test","author_name":"ν
μ€ν°","author_email":"[email protected]","content":"ν
μ€νΈ λκΈ"}'# Vercel CLI μ€μΉ
npm i -g vercel
# νλ‘μ νΈ λ°°ν¬
vercel --prod
# νκ²½ λ³μ μ€μ
vercel env add NOTION_API_KEY
vercel env add NEXT_PUBLIC_SUPABASE_URL- Vercel Dashboardμμ λλ©μΈ μΆκ°
- DNS μ€μ (A λ μ½λ/CNAME)
- SSL μΈμ¦μ μλ λ°κΈ π
// λκΈ λͺ©λ‘ μ‘°ν
GET /api/comments?slug=post-slug
// λκΈ μμ±
POST /api/comments
{
"post_slug": "string",
"author_name": "string",
"author_email": "string",
"content": "string",
"reply_to": "uuid" // μ νμ¬ν
}
// μ’μμ ν κΈ
POST /api/comments/[id]/like
// λκΈ ν΅κ³
GET /api/comments/custom-stats?slug=post-slug- μ΄λ©μΌ: [email protected]
- GitHub: @fullkeem
MIT License - μμΈν λ΄μ©μ LICENSE νμΌμ μ°Έμ‘°νμΈμ.