| name | code-templates |
| description | 실제 코드 템플릿 - Supabase 인증, UI 컴포넌트, 레이아웃 |
| triggers | 코드, 템플릿, 인증, 로그인, 회원가입 |
코드 템플릿
Claude Code가 프로젝트 생성 시 자동으로 생성하는 실제 코드입니다.
0. 환경 변수 검증 및 에러 처리
lib/env.ts
// 환경 변수 검증 유틸리티
type EnvConfig = {
NEXT_PUBLIC_SUPABASE_URL: string
NEXT_PUBLIC_SUPABASE_ANON_KEY: string
NEXT_PUBLIC_APP_URL: string
// 선택적
STRIPE_SECRET_KEY?: string
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY?: string
OPENAI_API_KEY?: string
}
export function validateEnv(): { valid: boolean; errors: string[] } {
const errors: string[] = []
// 필수 환경 변수 체크
if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
errors.push('NEXT_PUBLIC_SUPABASE_URL이 설정되지 않았습니다.')
} else if (!process.env.NEXT_PUBLIC_SUPABASE_URL.includes('supabase.co')) {
errors.push('NEXT_PUBLIC_SUPABASE_URL 형식이 올바르지 않습니다. (예: https://xxxxx.supabase.co)')
}
if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
errors.push('NEXT_PUBLIC_SUPABASE_ANON_KEY가 설정되지 않았습니다.')
} else if (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY.length < 100) {
errors.push('NEXT_PUBLIC_SUPABASE_ANON_KEY가 너무 짧습니다. 올바른 키인지 확인하세요.')
}
if (!process.env.NEXT_PUBLIC_APP_URL) {
errors.push('NEXT_PUBLIC_APP_URL이 설정되지 않았습니다. (예: http://localhost:3000)')
}
return {
valid: errors.length === 0,
errors
}
}
export function getEnvGuide(): string {
return `
📋 환경 변수 설정 가이드
1️⃣ .env.local 파일 생성
프로젝트 루트에 .env.local 파일을 만드세요.
2️⃣ Supabase 키 얻기
1. https://supabase.com/dashboard 접속
2. 프로젝트 선택 (없으면 "New Project" 클릭)
3. 왼쪽 메뉴 → Settings → API
4. 복사할 값:
- Project URL → NEXT_PUBLIC_SUPABASE_URL
- anon public → NEXT_PUBLIC_SUPABASE_ANON_KEY
3️⃣ .env.local 파일 내용
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
NEXT_PUBLIC_APP_URL=http://localhost:3000
4️⃣ 서버 재시작
터미널에서 Ctrl+C 누르고 다시 npm run dev
⚠️ 주의사항
- .env.local은 .gitignore에 포함되어야 합니다
- 키를 공개 저장소에 올리지 마세요
- Vercel 배포 시 Environment Variables에 추가하세요
`
}
app/api/health/route.ts
// 환경 변수 및 Supabase 연결 상태 확인 API
import { createClient } from '@/lib/supabase/server'
import { validateEnv, getEnvGuide } from '@/lib/env'
import { NextResponse } from 'next/server'
export async function GET() {
const result = {
status: 'ok',
timestamp: new Date().toISOString(),
env: {
valid: false,
errors: [] as string[],
guide: ''
},
supabase: {
connected: false,
error: ''
}
}
// 1. 환경 변수 검증
const envCheck = validateEnv()
result.env.valid = envCheck.valid
result.env.errors = envCheck.errors
if (!envCheck.valid) {
result.status = 'error'
result.env.guide = getEnvGuide()
return NextResponse.json(result, { status: 500 })
}
// 2. Supabase 연결 테스트
try {
const supabase = await createClient()
const { error } = await supabase.from('profiles').select('count').limit(1)
if (error) {
// 테이블이 없으면 생성 필요
if (error.code === '42P01') {
result.supabase.connected = true
result.supabase.error = '테이블이 없습니다. SQL 스크립트를 실행하세요.'
} else if (error.message.includes('Invalid API key')) {
result.status = 'error'
result.supabase.error = 'API 키가 유효하지 않습니다. Supabase 대시보드에서 다시 확인하세요.'
} else {
result.supabase.connected = true // 연결은 됐지만 다른 에러
result.supabase.error = error.message
}
} else {
result.supabase.connected = true
}
} catch (error) {
result.status = 'error'
result.supabase.error = error instanceof Error ? error.message : '연결 실패'
}
return NextResponse.json(result, {
status: result.status === 'ok' ? 200 : 500
})
}
components/env-check.tsx
'use client'
import { useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
type HealthStatus = {
status: string
env: {
valid: boolean
errors: string[]
guide: string
}
supabase: {
connected: boolean
error: string
}
}
export function EnvCheck() {
const [health, setHealth] = useState<HealthStatus | null>(null)
const [loading, setLoading] = useState(true)
const [showGuide, setShowGuide] = useState(false)
useEffect(() => {
checkHealth()
}, [])
async function checkHealth() {
setLoading(true)
try {
const res = await fetch('/api/health')
const data = await res.json()
setHealth(data)
} catch {
setHealth({
status: 'error',
env: { valid: false, errors: ['서버에 연결할 수 없습니다.'], guide: '' },
supabase: { connected: false, error: '' }
})
}
setLoading(false)
}
if (loading) {
return (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground">환경 설정 확인 중...</p>
</CardContent>
</Card>
)
}
if (!health) return null
// 모든 것이 정상이면 표시 안 함
if (health.status === 'ok' && health.supabase.connected && !health.supabase.error) {
return null
}
return (
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-red-700 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
환경 설정 문제
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 환경 변수 에러 */}
{!health.env.valid && (
<div className="space-y-2">
<p className="font-medium text-red-700">환경 변수 오류:</p>
<ul className="list-disc list-inside text-sm text-red-600 space-y-1">
{health.env.errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
{/* Supabase 연결 에러 */}
{health.supabase.error && (
<div className="space-y-2">
<p className="font-medium text-red-700">Supabase 연결 오류:</p>
<p className="text-sm text-red-600">{health.supabase.error}</p>
</div>
)}
{/* 해결 가이드 */}
<div className="pt-4 border-t border-red-200">
<Button
variant="outline"
size="sm"
onClick={() => setShowGuide(!showGuide)}
className="text-red-700 border-red-300 hover:bg-red-100"
>
{showGuide ? '가이드 닫기' : '해결 방법 보기'}
</Button>
{showGuide && (
<div className="mt-4 p-4 bg-white rounded-lg text-sm">
<h4 className="font-bold mb-2">📋 환경 변수 설정 가이드</h4>
<div className="space-y-4">
<div>
<p className="font-medium">1️⃣ .env.local 파일 생성</p>
<p className="text-muted-foreground">프로젝트 루트에 .env.local 파일을 만드세요.</p>
</div>
<div>
<p className="font-medium">2️⃣ Supabase 키 얻기</p>
<ol className="list-decimal list-inside text-muted-foreground ml-2">
<li><a href="https://supabase.com/dashboard" target="_blank" className="text-blue-600 hover:underline">supabase.com/dashboard</a> 접속</li>
<li>프로젝트 선택 (없으면 New Project)</li>
<li>Settings → API 메뉴 클릭</li>
<li>Project URL과 anon key 복사</li>
</ol>
</div>
<div>
<p className="font-medium">3️⃣ .env.local 파일 내용</p>
<pre className="bg-gray-100 p-2 rounded text-xs overflow-x-auto">
{`NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
NEXT_PUBLIC_APP_URL=http://localhost:3000`}
</pre>
</div>
<div>
<p className="font-medium">4️⃣ 서버 재시작</p>
<p className="text-muted-foreground">터미널에서 Ctrl+C 후 npm run dev</p>
</div>
</div>
<Button onClick={checkHealth} size="sm" className="mt-4">
다시 확인
</Button>
</div>
)}
</div>
</CardContent>
</Card>
)
}
에러 메시지 해석 가이드
| 에러 메시지 | 원인 | 해결 방법 |
|---|---|---|
환경 변수가 설정되지 않았습니다 |
.env.local 파일이 없거나 비어있음 | .env.local 파일 생성 및 키 입력 |
Invalid API key |
Supabase 키가 잘못됨 | Supabase 대시보드에서 키 다시 복사 |
URL 형식이 올바르지 않습니다 |
URL에 오타가 있음 | https://xxxxx.supabase.co 형식 확인 |
키가 너무 짧습니다 |
키를 일부만 복사함 | 전체 키 복사 (eyJ...로 시작하는 긴 문자열) |
테이블이 없습니다 |
DB 테이블 미생성 | Supabase SQL Editor에서 스크립트 실행 |
연결 실패 |
네트워크 문제 또는 프로젝트 삭제됨 | 인터넷 연결 확인, Supabase 프로젝트 상태 확인 |
자주 하는 실수
❌ 잘못된 예시들
1. URL 끝에 슬래시 붙임
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co/ ← 슬래시 제거!
2. 따옴표 사용
NEXT_PUBLIC_SUPABASE_URL="https://xxx.supabase.co" ← 따옴표 제거!
3. 공백 포함
NEXT_PUBLIC_SUPABASE_URL = https://xxx.supabase.co ← 공백 제거!
4. service_role 키 사용 (보안 위험!)
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...(service_role) ← anon 키 사용!
5. .env 파일 사용 (.env.local이 아님)
Next.js는 .env.local 파일을 우선 읽습니다.
✅ 올바른 예시
NEXT_PUBLIC_SUPABASE_URL=https://abcdefghijklmnop.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFiY2RlZmdoaWprbG1ub3AiLCJyb2xlIjoiYW5vbiIsImlhdCI6MTcwNDEyMzQ1NiwiZXhwIjoyMDE5Njk5NDU2fQ.xxxxx
NEXT_PUBLIC_APP_URL=http://localhost:3000
1. Supabase 설정
lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
'환경 변수가 설정되지 않았습니다.\n' +
'.env.local 파일에 다음 값을 설정하세요:\n' +
'- NEXT_PUBLIC_SUPABASE_URL\n' +
'- NEXT_PUBLIC_SUPABASE_ANON_KEY\n\n' +
'📋 설정 방법:\n' +
'1. https://supabase.com/dashboard 접속\n' +
'2. 프로젝트 선택 → Settings → API\n' +
'3. Project URL과 anon key 복사'
)
}
return createBrowserClient(supabaseUrl, supabaseAnonKey)
}
lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase 환경 변수가 설정되지 않았습니다.')
}
return createServerClient(
supabaseUrl,
supabaseAnonKey,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component에서 호출 시 무시
}
},
},
}
)
}
lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
// 보호된 경로 체크
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard')
const isAuthRoute = request.nextUrl.pathname.startsWith('/login') ||
request.nextUrl.pathname.startsWith('/signup')
if (isProtectedRoute && !user) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
if (isAuthRoute && user) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
middleware.ts (루트)
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
2. 인증 액션
app/actions/auth.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
return { error: error.message }
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const name = formData.get('name') as string
// 비밀번호 유효성 검사
if (password.length < 8) {
return { error: '비밀번호는 8자 이상이어야 합니다' }
}
const { error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: name,
},
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
},
})
if (error) {
return { error: error.message }
}
revalidatePath('/', 'layout')
redirect('/login?message=이메일을 확인해주세요')
}
export async function logout() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/')
}
export async function signInWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
},
})
if (error) {
return { error: error.message }
}
if (data.url) {
redirect(data.url)
}
}
export async function signInWithGitHub() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
},
})
if (error) {
return { error: error.message }
}
if (data.url) {
redirect(data.url)
}
}
export async function resetPassword(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/reset-password`,
})
if (error) {
return { error: error.message }
}
return { success: '비밀번호 재설정 링크를 이메일로 보냈습니다' }
}
export async function updatePassword(formData: FormData) {
const supabase = await createClient()
const password = formData.get('password') as string
if (password.length < 8) {
return { error: '비밀번호는 8자 이상이어야 합니다' }
}
const { error } = await supabase.auth.updateUser({
password,
})
if (error) {
return { error: error.message }
}
redirect('/dashboard?message=비밀번호가 변경되었습니다')
}
app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
// 에러 시 로그인 페이지로
return NextResponse.redirect(`${origin}/login?error=인증에 실패했습니다`)
}
3. 로그인 페이지
app/(auth)/login/page.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { login, signInWithGoogle, signInWithGitHub } from '@/app/actions/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export default function LoginPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const searchParams = useSearchParams()
const message = searchParams.get('message')
async function handleSubmit(formData: FormData) {
setLoading(true)
setError(null)
const result = await login(formData)
if (result?.error) {
setError(result.error)
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">로그인</CardTitle>
<CardDescription>계정에 로그인하세요</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{message && (
<div className="p-3 bg-green-50 text-green-700 rounded-md text-sm">
{message}
</div>
)}
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-md text-sm">
{error}
</div>
)}
{/* 소셜 로그인 */}
<div className="space-y-2">
<form action={signInWithGoogle}>
<Button type="submit" variant="outline" className="w-full">
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google로 계속하기
</Button>
</form>
<form action={signInWithGitHub}>
<Button type="submit" variant="outline" className="w-full">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub로 계속하기
</Button>
</form>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">또는</span>
</div>
</div>
{/* 이메일 로그인 */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
이메일
</label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
비밀번호
</label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
required
/>
</div>
<div className="flex items-center justify-between text-sm">
<Link href="/forgot-password" className="text-blue-600 hover:underline">
비밀번호 찾기
</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '로그인 중...' : '로그인'}
</Button>
</form>
<p className="text-center text-sm text-gray-600">
계정이 없으신가요?{' '}
<Link href="/signup" className="text-blue-600 hover:underline font-medium">
회원가입
</Link>
</p>
</CardContent>
</Card>
</div>
)
}
4. 회원가입 페이지
app/(auth)/signup/page.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { signup, signInWithGoogle, signInWithGitHub } from '@/app/actions/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export default function SignupPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(formData: FormData) {
setLoading(true)
setError(null)
// 비밀번호 확인
const password = formData.get('password') as string
const confirmPassword = formData.get('confirmPassword') as string
if (password !== confirmPassword) {
setError('비밀번호가 일치하지 않습니다')
setLoading(false)
return
}
const result = await signup(formData)
if (result?.error) {
setError(result.error)
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">회원가입</CardTitle>
<CardDescription>새 계정을 만드세요</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-md text-sm">
{error}
</div>
)}
{/* 소셜 회원가입 */}
<div className="space-y-2">
<form action={signInWithGoogle}>
<Button type="submit" variant="outline" className="w-full">
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google로 시작하기
</Button>
</form>
<form action={signInWithGitHub}>
<Button type="submit" variant="outline" className="w-full">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub로 시작하기
</Button>
</form>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">또는</span>
</div>
</div>
{/* 이메일 회원가입 */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
이름
</label>
<Input
id="name"
name="name"
type="text"
placeholder="홍길동"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
이메일
</label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
비밀번호
</label>
<Input
id="password"
name="password"
type="password"
placeholder="8자 이상"
minLength={8}
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
비밀번호 확인
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
placeholder="비밀번호 다시 입력"
minLength={8}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '가입 중...' : '회원가입'}
</Button>
</form>
<p className="text-center text-sm text-gray-600">
이미 계정이 있으신가요?{' '}
<Link href="/login" className="text-blue-600 hover:underline font-medium">
로그인
</Link>
</p>
</CardContent>
</Card>
</div>
)
}
5. UI 컴포넌트
components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
lib/utils.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
6. 레이아웃 컴포넌트
components/layout/header.tsx
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { logout } from '@/app/actions/auth'
import { Button } from '@/components/ui/button'
export async function Header() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 max-w-screen-2xl items-center">
<div className="mr-4 flex">
<Link href="/" className="mr-6 flex items-center space-x-2">
<span className="font-bold text-xl">MyApp</span>
</Link>
<nav className="flex items-center gap-4 text-sm">
<Link href="/features" className="transition-colors hover:text-foreground/80">
기능
</Link>
<Link href="/pricing" className="transition-colors hover:text-foreground/80">
가격
</Link>
<Link href="/docs" className="transition-colors hover:text-foreground/80">
문서
</Link>
</nav>
</div>
<div className="flex flex-1 items-center justify-end space-x-2">
{user ? (
<>
<Link href="/dashboard">
<Button variant="ghost" size="sm">대시보드</Button>
</Link>
<form action={logout}>
<Button variant="outline" size="sm">로그아웃</Button>
</form>
</>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">로그인</Button>
</Link>
<Link href="/signup">
<Button size="sm">시작하기</Button>
</Link>
</>
)}
</div>
</div>
</header>
)
}
components/layout/footer.tsx
import Link from 'next/link'
export function Footer() {
return (
<footer className="border-t">
<div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
Built with KreatSaaS. The source code is available on{" "}
<Link
href="https://github.com"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub
</Link>
.
</p>
</div>
<div className="flex gap-4">
<Link href="/privacy" className="text-sm text-muted-foreground hover:underline">
개인정보처리방침
</Link>
<Link href="/terms" className="text-sm text-muted-foreground hover:underline">
이용약관
</Link>
</div>
</div>
</footer>
)
}
7. 대시보드 페이지
app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="container py-10">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">대시보드</h1>
<p className="text-muted-foreground">
안녕하세요, {user.user_metadata?.full_name || user.email}님!
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">총 방문자</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">1,234</div>
<p className="text-xs text-muted-foreground">
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">활성 사용자</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+573</div>
<p className="text-xs text-muted-foreground">
+201 since last hour
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">매출</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">₩12,345,000</div>
<p className="text-xs text-muted-foreground">
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">전환율</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">3.2%</div>
<p className="text-xs text-muted-foreground">
+0.5% from last month
</p>
</CardContent>
</Card>
</div>
</div>
)
}
8. 랜딩 페이지
app/page.tsx
import Link from 'next/link'
import { Button } from '@/components/ui/button'
export default function HomePage() {
return (
<div className="flex flex-col min-h-screen">
{/* Hero Section */}
<section className="flex-1 flex flex-col items-center justify-center text-center px-4 py-20">
<div className="max-w-3xl space-y-6">
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
당신의 아이디어를
<span className="text-primary"> 현실</span>로
</h1>
<p className="text-xl text-muted-foreground">
복잡한 개발 없이 누구나 쉽게 SaaS를 만들 수 있습니다.
지금 바로 시작하세요.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/signup">
<Button size="lg" className="w-full sm:w-auto">
무료로 시작하기
</Button>
</Link>
<Link href="/demo">
<Button size="lg" variant="outline" className="w-full sm:w-auto">
데모 보기
</Button>
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 bg-muted/50">
<div className="container">
<h2 className="text-3xl font-bold text-center mb-12">주요 기능</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center space-y-4">
<div className="mx-auto w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold">빠른 시작</h3>
<p className="text-muted-foreground">
5분 안에 프로젝트를 시작하고 배포할 수 있습니다.
</p>
</div>
<div className="text-center space-y-4">
<div className="mx-auto w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-xl font-semibold">안전한 인증</h3>
<p className="text-muted-foreground">
소셜 로그인과 이메일 인증을 기본으로 제공합니다.
</p>
</div>
<div className="text-center space-y-4">
<div className="mx-auto w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<svg className="w-6 h-6 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<h3 className="text-xl font-semibold">결제 연동</h3>
<p className="text-muted-foreground">
Stripe, 토스페이먼츠 등 다양한 결제를 지원합니다.
</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20">
<div className="container text-center">
<h2 className="text-3xl font-bold mb-4">지금 바로 시작하세요</h2>
<p className="text-muted-foreground mb-8">
무료로 시작하고, 필요할 때 업그레이드하세요.
</p>
<Link href="/signup">
<Button size="lg">무료로 시작하기</Button>
</Link>
</div>
</section>
</div>
)
}
9. 글로벌 스타일
app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
10. 루트 레이아웃
app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"
import { Header } from "@/components/layout/header"
import { Footer } from "@/components/layout/footer"
const inter = Inter({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "My SaaS - 당신의 아이디어를 현실로",
description: "복잡한 개발 없이 누구나 쉽게 SaaS를 만들 수 있습니다.",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="ko">
<body className={inter.className}>
<Header />
<main className="min-h-screen">{children}</main>
<Footer />
</body>
</html>
)
}
11. Supabase 테이블 설정 (SQL)
Supabase SQL Editor에서 실행
-- 사용자 프로필 테이블
CREATE TABLE profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
email TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc', NOW())
);
-- 새 사용자 생성 시 프로필 자동 생성
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- RLS 정책
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public profiles are viewable by everyone." ON profiles
FOR SELECT USING (true);
CREATE POLICY "Users can update own profile." ON profiles
FOR UPDATE USING (auth.uid() = id);
Claude Code 사용 지시
이 템플릿을 프로젝트에 적용할 때:
- 폴더 구조 생성
mkdir -p src/app/(auth)/login src/app/(auth)/signup src/app/auth/callback
mkdir -p src/app/dashboard src/app/actions
mkdir -p src/components/ui src/components/layout
mkdir -p src/lib/supabase
각 파일 생성 - 위 템플릿 코드를 해당 경로에 생성
환경 변수 설정 - .env.local 파일 생성
Supabase 설정 - SQL 스크립트 실행