admin-starter-kit 만들기 (1)
Next.js 16 + Supabase로 어드민 대시보드 만들기 (1)
– 초기 세팅과 에러 디버깅 로그
0. 프로젝트 스택 정리
- Framework: Next.js 16 (App Router,
app디렉터리 구조) - UI: Tailwind CSS, Radix UI 기반 컴포넌트
- Auth / Backend: Supabase (
@supabase/ssr,@supabase/supabase-js) - 언어: TypeScript
이번 글에서는 초기 개발 단계에서 만난 몇 가지 에러를 정리하고, 그것을 어떻게 이해하고 해결(또는 해결 방향을 설계)했는지 기록한다.
1. 랜딩 페이지에서 next/router 에러
랜딩 페이지 파일은 src/app/(marketing)/page.tsx에 두었다.
버튼 클릭으로 로그인 페이지(/login)로 보내기 위해 아래와 같이 작성했었다.
// src/app/(marketing)/page.tsx
import { Button } from '@/components/ui/button';
import { Router, useRouter } from 'next/router';
export default function MarketingPage() {
const router = useRouter();
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<h1 className="text-3xl font-bold">여기는 랜딩 페이지입니다</h1>
<Button onClick={() => router.push('/login')}>로그인 하러가기</Button>
</div>
);
}
Next.js가 알려준 에러는 다음과 같다.
You have a Server Component that imports next/router. Use next/navigation instead.
1-1. 왜 이런 에러가 나는가?
src/app/(marketing)/page.tsx는 App Router 환경의 Server Component다.- App Router에서는:
next/router는 Pages Router 전용 API라서 사용 불가- 대신 **
next/navigation**의useRouter,redirect,useSearchParams등을 사용해야 한다. - 그리고
useRouter훅은 클라이언트 컴포넌트에서만 사용할 수 있다.
즉, 이 페이지는 “서버 컴포넌트 + next/router + 훅”이라는 조합 때문에 에러가 난 것이다.
1-2. 해결 방향 정리
이 문제를 해결하는 방법은 크게 두 가지 방향이 있다.
-
페이지를 클라이언트 컴포넌트로 전환
- 파일 상단에
"use client"; next/router→next/navigation으로 변경useRouter를 그대로 사용하면서 버튼 클릭 시router.push('/login')
- 파일 상단에
-
Server Component 유지 +
Link컴포넌트 사용useRouter대신next/link의Link로 네비게이션 처리- Supabase나 React 훅이 필요 없고, 단순 이동만 필요하다면 이 방법이 훨씬 간단
이번 글에서는 에러의 원인과 해결 전략까지만 정리했고, 실제 구현 선택과 리팩터링은 이후 단계에서 진행할 예정이다.
2. Supabase 서버 클라이언트에서 cookies() 타입 에러
Supabase 서버용 클라이언트는 src/lib/supabase/server.ts에 만들었다.
처음 버전은 대략 이런 형태였다.
// src/lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// Server Component에서는 쿠키 조작 불가 에러 무시
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// Server Component에서는 쿠키 조작 불가 에러 무시
}
},
},
}
);
}
TypeScript 린터가 잡아준 에러는 다음과 같았다.
Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'.Property 'set' does not exist on type 'Promise<ReadonlyRequestCookies>'.
2-1. 에러 원인
프로젝트의 package.json을 보면:
next:16.1.6@supabase/ssr:^0.8.0
이 버전 조합에서는 next/headers의 cookies()가 동기 객체가 아니라 Promise<ReadonlyRequestCookies>로 타입 정의되어 있다.
그런데 기존 코드에서는 const cookieStore = cookies();라고 동기처럼 사용하고 있었기 때문에:
cookieStore타입이Promise<ReadonlyRequestCookies>- 따라서
cookieStore.get,cookieStore.set등은 존재하지 않는다는 에러가 발생
2-2. 해결: createClient를 async로 변경
이 문제를 해결하기 위해 서버용 클라이언트 팩토리를 비동기 함수로 변경했다.
// src/lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options });
} catch (error) {
// Server Component에서는 쿠키 조작 불가 에러 무시
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options });
} catch (error) {
// Server Component에서는 쿠키 조작 불가 에러 무시
}
},
},
}
);
}
핵심 변화는 두 줄이다.
export function createClient()→export async function createClient()const cookieStore = cookies();→const cookieStore = await cookies();
이렇게 한 뒤 다시 린트를 돌리면, server.ts의 타입 에러는 모두 사라진다.
3. Dashboard 레이아웃에서 Supabase 클라이언트 사용 에러
Supabase 서버 클라이언트를 만든 다음, 대시보드 레이아웃에서 로그인된 사용자 정보를 서버에서 직접 가져오기 위해 아래와 같이 작성했었다.
// src/app/dashboard/layout.tsx
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// 1. 서버에서 로그인 정보 가져오기
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// 2. 혹시나 미들웨어가 뚫렸을 경우를 대비한 이중 체크
if (!user) {
redirect('/login');
}
return (
// ...
);
}
하지만 createClient()를 앞에서 async 함수로 바꿨기 때문에, 여기에서도 새로운 타입 에러가 발생했다.
Property 'auth' does not exist on type 'Promise<SupabaseClient<...>>'.
즉, supabase가 SupabaseClient가 아니라 Promise<SupabaseClient> 타입이 되어버린 것이다.
3-1. 해결: await createClient()로 호출
레이아웃 함수가 이미 async이기 때문에, 여기서는 단순히 await만 붙여주면 된다.
// src/app/dashboard/layout.tsx
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// 1. 서버에서 로그인 정보 가져오기
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
return (
<div className="h-full relative">
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-gray-900">
<Sidebar />
</div>
<main className="md:pl-72 h-full">
<div className="h-[60px] md:h-[80px] fixed inset-y-0 w-full z-50 md:pl-72">
<Header userEmail={user.email} />
</div>
<div className="flex flex-col h-full pt-16 md:pt-20">
<div className="p-8 h-full bg-slate-50/50">{children}</div>
</div>
</main>
</div>
);
}
이렇게 수정한 뒤에는:
DashboardLayout에서의 Supabase 관련 타입 에러가 모두 해결- 남은 것은 Tailwind 관련 린트 경고(예:
z-[80]대신z-80권장) 정도라서, 실제 빌드/실행에는 문제가 없다.
4. 여기까지 정리
지금까지 진행한 내용은 다음과 같다.
-
랜딩 페이지(
(marketing)/page.tsx)- App Router에서
next/router를 사용해 발생한 에러를 분석 next/navigation+ Client Component vsLink기반 Server Component라는 두 가지 해결 전략을 정리
- App Router에서
-
Supabase 서버 클라이언트(
lib/supabase/server.ts)cookies()가Promise로 타입 지정된 Next 16 환경에서의 타입 에러를 확인createClient를async함수로 변경하고await cookies()로 수정하여 해결
-
대시보드 레이아웃(
app/dashboard/layout.tsx)createClient가 async로 바뀌면서 생긴Promise타입 문제를await createClient()로 해결- 로그인한 사용자가 없으면
redirect('/login')로 보호하는 서버 사이드 가드 구현