실제 코드 템플릿 - Supabase 인증, UI 컴포넌트, 레이아웃
Provides Supabase authentication, UI components, and layout templates for Next.js projects. Automatically triggers when creating new projects to generate complete auth flows with email/social login, environment validation, and reusable UI components.
/plugin marketplace add johunsang/kreatsaas/plugin install kreatsaas@kreatsaas-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Claude Code가 프로젝트 생성 시 자동으로 생성하는 실제 코드입니다.
// 환경 변수 검증 유틸리티
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에 추가하세요
`
}
// 환경 변수 및 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
})
}
'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
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)
}
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에서 호출 시 무시
}
},
},
}
)
}
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
}
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)$).*)',
],
}
'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=비밀번호가 변경되었습니다')
}
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=인증에 실패했습니다`)
}
'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>
)
}
'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>
)
}
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 }
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 }
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 }
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
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>
)
}
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>
)
}
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>
)
}
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>
)
}
@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;
}
}
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>
)
}
-- 사용자 프로필 테이블
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);
이 템플릿을 프로젝트에 적용할 때:
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 스크립트 실행
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.