공식 문서 보기를 돌같이 하는 버릇을 고치자!
예기치 못한 에러 발생은 사용자에게 안 좋은 경험을 심어준다. 거기에 에러 스택까지 보여준다면? 사용자에게도, 개발자에게도 최악의 페이지가 될 것이다. Next.js
에서는 파일로 에러를 핸들링하는 기능을 제공한다. 에러 처리가 필요한 폴더 하위에 error.tsx
를 추가한다.
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service
console.error(error);
}, [error]);
return (
<main className="flex h-full flex-col items-center justify-center">
<h2 className="text-center">Something went wrong!</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
onClick={
// Attempt to recover by trying to re-render the invoices route
() => reset()
}
>
Try again
</button>
</main>
);
}
error
는 JS
의 에러 객체이고, reset
은 에러 경계로 되돌리는 함수이다. reset
을 실행하면 에러가 발생한 라우트의 리렌더링을 시도하도록 유도할 수 있다.
error.tsx
는 전체 에러를 핸들링하는 역할이라면, not-found.tsx
는 404 error를 핸들링한다. not-found
가 필요한 라우트 하위에 파일을 생성한다.
// app/dashboard/invoices/[id]/edit/not-found.tsx
import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';
export default function NotFound() {
return (
<main className="flex h-full flex-col items-center justify-center gap-2">
<FaceFrownIcon className="w-10 text-gray-400" />
<h2 className="text-xl font-semibold">404 Not Found</h2>
<p>Could not find the requested invoice.</p>
<Link
href="/dashboard/invoices"
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
>
Go Back
</Link>
</main>
);
}
페이지에서는 notfound()
함수를 호출한다.
// app/dashboard/invoices/[id]/edit/page.tsx
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
+ import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
+ if (!invoice) {
+ notFound();
+ }
// ...
}
접근성은 장애인을 포함한 모든 사람이 사용할 수 있는 애플리케이션을 설계하고 구현하는 것을 말한다. 참고 web.dev - learn accessibility
Next.js
는 기본적으로 eslint-plugin-jsx-a11y
를 포함하고 있다. 이 플러그인은 aria-*
, alt
, role
등 접근성에 대한 이슈를 체크해준다. "lint": "next lint"
스크립트를 추가하여 검사할 수 있다. 예를 들어, 이미지의 alt
가 없다면 검사했을 때의 경고 문구이다.
./app/ui/invoices/table.tsx
29:23 Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
사용하고 있는 form
에서는 접근성 향상을 위해 3가지를 이미 실천하고 있다.
div
대신 input
, option
등의 시멘틱 태그를 사용하여 사용자에게 양식을 탐색하고 이해하기 쉽게 만든다.label
과 htmlFor
를 사용하면 사용자가 label를 클릭하여 해당 입력 필드에 집중할 수 있다.일반적인 접근성 설정을 했지만, form validation을 충족하지는 못한다. 현재 비어 있는 양식을 제출하면 에러가 발생하기 때문이다.
클라이언트 측에서 간단한 대응은 input
에 required
속성을 추가하는 것이다.
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
+ required
/>
일반적으로는 괜찮지만, dev tool에서 해당 요소의 required
속성을 지우면 검증이 먹히지 않는다. Next.js
에서는 서버 측 대안을 제시한다. 서버 측 검증에서는
useFormState
를 사용하여 서버 측 검증을 진행한다. 훅을 사용하므로 use client
를 명시해 클라이언트 컴포넌트로 전환한다.
'use client';
// ...
import { useFormState } from 'react-dom';
useFormState
는 useReducer와 유사한 실험적 기능이다. action
과 initialState
를 인자로 받고, state
와 dispatch
를 반환한다. <form action={}>
에 dispatch
를 주입한다.
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(createInvoice, initialState);
return <form action={dispatch}>...</form>;
}
zod
를 이용해 검증할 데이터 스키마를 추가한다.
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
useFormState
를 위해 createInvoice
에 prevState
를 추가한다. 사용하지 않더라도 useFormState
가 필수로 요구하기 때문이다.
// This is temporary until @types/react-dom is updated
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
zod
의 parse
대신 safeParse
로 교체한다. safeParse
는 성공 또는 실패 시의 객체를 반환하여 try/catch
구문에 넣지 않아도 유효성 검사를 처리할 수 있다.
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form fields using Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// If form validation fails, return errors early. Otherwise, continue.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
// ...
}
검증을 통과하지 못하면 useFormState
의 state
는 다음과 같은 에러 객체를 반환한다.
{
errors: { customerId: Array(1), amount: Array(1), status: Array(1) },
message: "Missing Fields. Failed to Create Invoice."
}
에러가 발생하면 화면에 표시하는 코드를 작성한다.
<form action={dispatch}>
{/* ... */}
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
+ aria-describedby="customer-error"
>
{/* ... */}
</select>
+ <div id="customer-error" aria-live="polite" aria-atomic="true">
+ {state.errors?.customerId &&
+ state.errors.customerId.map((error: string) => (
+ <p className="mt-2 text-sm text-red-500" key={error}>
+ {error}
+ </p>
+ ))}
+ </div>
{/* ... */}
</form>
aria-describedby="customer-error"
는 id="customer-error"
와 관계를 형성해 오류가 발생하면 화면 판독기가 해당 설명을 읽게 된다. aria-live="polite"
는 오류가 발생했음을 알릴 때 사용자를 방해하지 않고 유휴 상태일 때만 알린다.
Authentication(인증)은 웹앱의 핵심 중 하나로, 실제 유저와 시스템이 바라는 유저가 같은지 확인하는 단계이다. 인증의 방법에는 여러 가지가 있다. 아이디와 비밀번호 입력 후 인증 코드를 발급한다거나 Google이나 Naver 등의 서드 파티를 이용한다. 혹은 외부 앱을 이용한 2단계 인증(2FA)으로 보안을 강화할 수도 있다. 이러한 인증 방법은 로그인 정보에 접근하더라도 고유 토큰 없이는 액세스할 수 없도록 한다.
웹 개발에서 Authentication과 Authorization은 비슷하지만 서로 다른 역할을 하는 개념이다.
Authentication(인증)은 사용자가 자신이 말한 사람이 맞는지 확인하는 것이다.
Authorization(인가)은 사용자의 신원이 확인되면 애플리케이션의 어떤 부분에 대한 권한이 있는지 결정하는 것이다.
여기서 사용하는 인증 라이브러리는 NextAuth.js
이다. 세션 관리, 로그인 및 로그아웃, 기타 인증과 관련된 복잡한 과정에 대한 솔루션을 제공한다. Next.js @14
와 호환되는 버전을 npm install next-auth@beta
로 설치한다.
다음으로 openssl rand -base64 32
로 비밀키를 생성하고, .env
에 추가한다.
AUTH_SECRET=your-secret-key
루트 폴더에 auth.config.ts
파일을 생성해 NextAuth
에 대한 옵션을 추가한다.
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirect unauthenticated users to login page
} else if (isLoggedIn) {
return Response.redirect(new URL('/dashboard', nextUrl));
}
return true;
},
},
providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;
pages
옵션을 통해 로그인, 로그아웃, 오류 페이지 경로 등을 지정할 수 있다. signIn: '/login'
을 추가하면 NextAuth
의 기본 페이지가 아닌 커스텀 로그인 페이지로 리디렉션한다(고 한다. 뭔 소린지 모르겠다.).
callbacks
은 Next.js
의 middleware
를 통해 로그인 요청이 완료되기 전 권한이 있는지 확인하는 데 사용한다. auth
는 유저의 session, request
속성 등이 들어있다.
providers
는 다양한 로그인 옵션을 나열하는 배열이다. NextAuth
의 구성을 충족하기 위해 일단 빈 배열로 설정되어 있다.
authConfig
객체를 가져올 middleware.ts
파일도 루트 폴더에 생성한다.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};
NextAuth
객체를 초기화하고, matcher
에 따라 middleware
를 실행한다. 배열에 담긴 문자열은 api
나 _next/static
, _next/image
, .png
로 끝나는 경로를 제외하고 middleware
를 실행한다는 의미이다. 이렇게 하면 미들웨어가 보호된 경로에서 인증이 확인될 때까지 렌더링을 하지 않아 성능 향상의 이점이 생긴다.
패스워드는 보통 생성할 때 해싱하여 저장한다. 이 프로젝트에서는 bcrypt
패키지를 사용하여 해싱 처리했는데, 문제는 Node API에 의존하는 패키지이기 때문에 미들웨어에서는 실행할 수 없다는 점이다. 이를 위해 authConfig
객체를 퍼트리는 auth.ts
파일을 새로 만든다.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
});
NextAuth
에 providers
옵션을 추가해야 한다. providers
는 Google 또는 GitHub와 같은 다양한 로그인 옵션을 나열하는 배열이다. 여기서는 Credentials provider만 사용한다.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [Credentials({})],
});
zod
를 사용하여 이메일과 비밀번호 유효성을 검사한 후 유저 정보를 확인하자.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
async function getUser(email: string): Promise<User | undefined> {
try {
const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
return user.rows[0];
} catch (error) {
console.error('Failed to fetch user:', error);
throw new Error('Failed to fetch user.');
}
}
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z
.object({ email: z.string().email(), password: z.string().min(6) })
.safeParse(credentials);
if (parsedCredentials.success) {
const { email, password } = parsedCredentials.data;
const user = await getUser(email);
if (!user) return null;
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) return user;
}
return null;
},
}),
],
});
로그인 action
을 작성한 다음 form을 수정한다.
// lib/actions
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
// ...
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn('credentials', formData);
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return 'Something went wrong.';
}
}
throw error;
}
}
// login-form
'use client';
import { lusitana } from '@/app/ui/fonts';
import {
AtSymbolIcon,
KeyIcon,
ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@/app/ui/button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';
export default function LoginForm() {
const [errorMessage, dispatch] = useFormState(authenticate, undefined);
return (
<form action={dispatch} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className={`${lusitana.className} mb-3 text-2xl`}>
Please log in to continue.
</h1>
<div className="w-full">{/* ... */}</div>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<>
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
<p className="text-sm text-red-500">{errorMessage}</p>
</>
)}
</div>
</div>
</form>
);
}
function LoginButton() {
const { pending } = useFormStatus();
return (
<Button className="mt-4 w-full" aria-disabled={pending}>
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
</Button>
);
}
로그아웃 기능도 추가한다.
import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
import { signOut } from '@/auth';
export default function SideNav() {
return (
<div className="flex h-full flex-col px-3 py-4 md:px-2">
// ...
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
<NavLinks />
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
<form
action={async () => {
'use server';
await signOut();
}}
>
<button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
<PowerIcon className="w-6" />
<div className="hidden md:block">Sign Out</div>
</button>
</form>
</div>
</div>
);
}
use server
를 최상단이 아닌 action
속성 내에 선언하는 게 신기했다. 이게 그 유명한 밈으로 탄생한 부분인 듯하다.
아무튼 다 작성하고 나니 위 authConfig
에서 궁금했던 signIn: '/login'
의 정체를 알았다. 이것을 설정하지 않으면 비로그인 유저가 인증이 필요한 페이지로 접근했을 때 리디렉션하지 않고 not-found
를 실행한다. 설정했다면 자동으로 로그인 페이지로 연결한다.
SEO와 콘텐츠 공유에 관련해서 웹앱의 metadata
는 매우 중요한 요소이다. 최종장에서는 metadata
에 대해서 학습한다.
metadata
는 웹 페이지에 대한 추가 세부 정보를 제공한다. 사용자에게는 표시되지 않지만, <head>
태그 내에 포함되어 백그라운드에서 동작한다. 이는 검색 엔진 등이 웹 페이지를 더 잘 이해하도록 돕는다. SEO를 향상시키는 데 중요한 역할을 하며, 적절한 metadata는 검색 엔진에서 웹 페이지의 순위를 높인다.
Next.js
가 제공하는 Metadata API에는 두 가지 방식이 있다. 하나는 Config-based
로, 정적인 metadata 객체를 export하거나 동적으로 metadata를 생성하는 함수를 layout.tsx
나 page.tsx
에서 export하는 방식이다. 다른 하나는 File-based
로, favicon.ico
, opengraph-image.jpg
, robots.txt
, sitemap.xml
등 특수한 목적의 이름을 가진 파일들을 사용한다. 두 방식을 사용하면 Next.js
가 자동으로 적절한 <head>
요소를 생성한다.
예시로, /public
폴더에 favicon.ico
와 opengraph-image.jpg
파일이 들어있다. 이것을 /app
폴더로 옮기면 자동으로 head
에 추가된다.
layout.tsx
나 page.tsx
파일 내에 metadata
객체를 포함할 수도 있다.
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Acme Dashboard',
description: 'The official Next.js Course Dashboard, built with App Router.',
metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};
export default function RootLayout() {
// ...
}
특정 페이지에 추가하여 해당 페이지의 정보만 담을 수 있다.
// dashboard/invoices
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Invoices | Acme Dashboard',
};
export default async function Page() {}
하지만 이렇게 입력하면 문제가 하나 있다. 회사명과 같은 주요 정보가 바뀌었을 때 모든 페이지의 metadata를 찾아 수정해야 한다. 그렇게 하는 대신 title.template
을 사용하여 기본값을 정하고 페이지마다 다른 title
을 부여할 수 있다.
// app/layout.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | Acme Dashboard',
default: 'Acme Dashboard',
},
description: 'The official Next.js Learn Dashboard built with App Router.',
metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};
// dashboard/invoices
export const metadata: Metadata = {
title: 'Invoices',
};