Next.js 14 기초 활용법
Next.js 14의 기초 활용법을 정리해봤다.
1. 프로젝트 만들기
create-next-app
을 활용해서 간단하게 프로젝트를 생성할 수 있다.
npx create-next-app
명령어 입력 후 TS 사용여부, Tailwind 사용여부, App router 사용여부 등 물음에 답하면 그에 맞게 프로젝트를 생성해 준다.
2. CSS 스타일링
원하는 스타일링 방식(CSS-in-JS, CSS Modules, Tailwind 등)을 선택해서 사용하면 된다.
2-1. Global 스타일
모든 루트에 적용될 스타일을 global.css
파일에 적어 놓는다.
해당 파일을 루트 레이아웃에서 import해서 적용한다.
/* /app/layout.tsx */
import '@/path/global.css'
3. 폰트와 이미지
3-1. Next.js가 제공하는 폰트 최적화
- 빌드 타임에 폰트 파일 미리 다운로드 후 번들에 넣어 놈
- 유저가 브라우저에서 앱 켰을 때 폰트 다운로드 하지 않음
- 폰트 다운로드 때문에 발생하는 폰트 깜빡임 등의 문제 발생하지 않음
3-2. 폰트 추가
next/font/google
모듈에서 사용하고 싶은 폰트를 import 한 뒤에 subset, wieght
등 정보를 설정해주고 다시 export한다.
/* font.ts */
import { Inter } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });
루트 레이아웃의 <body>
element에 폰트를 추가하면, 앱 전체에 폰트가 적용된다.
import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} antialiased`}>{children}</body>
</html>
);
}
다른 폰트를 추가하고 싶다면 위와 같은 방식으로 폰트를 사용하고 싶은 element에 추가해주면 된다.
3-3. Next.js가 제공하는 이미지 최적화
- 이미지 로딩 시 layout shift 방지
- 이미지 resizing
- 이미지 lazy load (viewport에 이미지 들어오면)
- 이미지 webp, avif 지원하는 브라우저이면 해당 형태로 제공
3-4. 이미지 추가
import Image from 'next/image';
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
<img>
태그 대신 Image
를 사용하면 된다. src, width, height, alt
는 required이다.
4. Layout과 Page
Next.js는 파일 기반 라우팅을 제공한다. 폴더 구조가 app / dashboard / invoices 이면 url이 domain.com/dashboard/invoices
로 구성된다.
각 라우트는 크게 layout.tsx
와 page.tsx
를 생성하여 UI를 구성할 수 있다.
layout.tsx
: 라우트내에서 공유해서 쓰는 UI / Sider나 Footer 등을 넣는다.page.tsx
: 라우트 url로 접근 시 해당 파일의 UI를 보여준다.- page 컴포넌트가 업데이트 되어도 layout 컴포넌트는 리렌더하지 않는다.
5. Page Navigation
5-1. <Link>
<Link>
로 client-side navigation을 할 수 있다.
<a>
와 유사하게 사용할 수 있다.
<Link
key={link.name}
href={link.href}
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"
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
5-2. Next.js가 제공하는 Navigation 최적화
- 자동으로 라우트별 코드 스플릿
- production 빌드에서, viewport에 있는
<Link>
의 코드를 prefetch<Link>
클릭시 이동할 페이지 미리 로드되어 있어 빠르게 이동 -> 사용자 경험 향상
5-3. 패턴: 링크 활성화 표시
현재 사용자가 어떤 link 페이지에 있는지 표시해주기 위해, 다음과 같은 패턴을 활용할 수 있다.
next/navigation
의usePathname
훅을 활용하여 현재 url의 pathname을 뽑아낸다.<Link>
들의 href 중 pathname과 동일한 것에 강조 표시를 해준다.
'use client'; // hook은 클라이언트 컴포넌트에서만 쓸 수 있기 때문에 추가
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
// ...
export default function NavLinks() {
const pathname = usePathname();
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className={clsx(
'일반 스타일',
{
'강조 스타일': pathname === link.href,
},
)}
>
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}
6. DB 셋업
공식문서 예제는 Vercel을 활용
실무 시에는 VM이나 AWS등 다른 클라우드에 DB 올리고 쓸 것이기 때문에 해당 케이스 작업 진행 후 정리
7. 데이터 fetch
- API Layer
- API 제공하는 써드 파티 서비스 사용 시
- 클라이언트 단에서 데이터 fetch 시
- DB 쿼리
- 서버 컴포넌트 사용 시
- DB 시크릿 클라이언트 단에 노출 없이 DB에 쿼리를 던질 수 있다.
- 서버 컴포넌트 사용 시
- ORM (ex: Prisma) or SQL 사용
7-1. 서버 컴포넌트 활용하여 데이터 fetch시 장점
Next.js는 default로 리액트 서버 컴포넌트 사용, 몇가지 장점이 있다.
- promises 지원 /
async/await
활용하여useEffect
,useState
,react query
활용 없이 데이터 fetch 가능 - 서버에서 데이터 fetch 동작 / 클라이언트로 결과만 보냄
- API Layer 없이 DB에 쿼리 던질 수 있음
7-2. DB 쿼리 만들기
Vercel Postgress SDK
(장점: SQL injection 방어)와 SQL 활용
import { sql } from '@vercel/postgres';
서버 컴포넌트 내에서 sql
을 호출할 수 있다. 하지만 서버 컴포넌트와 sql 호출 함수를 따로 분리해 두는 것이 좋다.
/// sql 호출 함수
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
try {
const data = await sql<Revenue>`SELECT * FROM revenue`;
return data.rows;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch revenue data.');
}
}
7-3. 서버 컴포넌트에서 데이터 fetch
위의 sql을 서버컴포넌트에서 호출하여 데이터를 가져온다.
서버 컴포넌트는 async를 붙여 fetch후 resolve된 데이터가 담길 수 있도록 한다.
export default async function RevenueChart() {
const revenue = await fetchRevenue(); // Fetch data inside the component
return (
<div className="w-full md:col-span-4">
// 생략
{revenue.map((month) => (
<div key={month.month} className="flex flex-col items-center gap-2">
<div
className="w-full rounded-md bg-blue-300"
style={{
height: `${(chartHeight / topLabel) * month.revenue}px`,
}}
></div>
<p className="-rotate-90 text-sm text-gray-400 sm:rotate-0">
{month.month}
</p>
</div>
))}
</div>
);
}
7-4. 서버 컴포넌트 활용하여 데이터 fetch시 단점
- request waterfall: 데이터 요청이 다른 동작을 block할 수 있다.
- 부모 컴포넌트 데이터 요청 완료되고 자식 컴포넌트 데이터 요청 시작됨
- Next.js는 default로 Static 렌더링 방식을 사용하기 때문에 데이터가 변경되어도 대시보드에 반영되지 않는다.
7-5. 병렬로 데이터 fetch
request waterfall을 방지하기 위해 동시에 여러 요청을 하는 방식
Promise.all()
or Promise.allSettled()
활용 한다. 동시에 여러 요청을 한다.
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
이 방식은 한 요청이 다른 요청들에 비해 많이 느릴 경우나 한 요청이 reject 되었을 때 문제가 생긴다.
Promise.all
은 인자로 주어진 모든 Promise들이 resolve되었을 때 resolve된다. 따라서 다른 요청들은 이미 resolve 되었지만 느린 요청 때문에 그 다음 작업을 진행하지 못한다.
8. Static과 Dynamic 렌더링
8-1. Static 렌더링
빌드 타임 or 배포 or revalidation 시에 서버에서 데이터 fetch, 렌더링이 수행됨 (결과 캐시 가능)
장점
- 빠른 속도: 캐시
- 서버 부하 줄음: 캐시
- SEO: 미리 렌더된 HTML이 있으므로
단점
- 변경된 데이터 반영 안됨
활용
블로그 포스트나 상품 페이지 같이 data 별로 없거나, 유저들 사이에서 공유되는 data 쓰는 UI에 활용
대시보드와 같은 개인화되고 정기적으로 업데이트되는 경우엔 잘 안맞는다.
방법
7.에서 사용한 방법
8-2. Dynamic 렌더링
request 타임에 데이터 fetch, 렌더링이 수행됨
장점
- 실시간 데이터
- 유저별 특정한 컨텐츠
- request 타임에만 알 수 있는 정보에 접근 가능 ex) 쿠키 or URL params
단점
- 요청 응답이 오래 걸릴 경우에 사용자도 그 시간만큼 기다려야 함
활용
대시보드 등 data 자주 업데이트 되고 유저별로 data 다른 경우
방법
sql문 활용하여 fetch하는 함수에 noStore
활용하여 response 캐시 막을 수 있다.
import { unstable_noStore as noStore } from 'next/cache';
export async function fetchRevenue() {
// Add noStore() here to prevent the response from being cached.
// This is equivalent to in fetch(..., {cache: 'no-store'}).
noStore();
// ...
}
이 방식으로 해당 데이터를 담은 UI url 요청 시에 최신 데이터를 받아볼 수 있다.
9. Streaming
라우트에서 렌더된 chunk들을 먼저 보여주는 방법
느린 데이터 요청들이 전체 페이지 렌더를 막는 문제를 해결한다.
9-1. 방법
- page 레벨은
loading.tsx
사용 - 컴포넌트 레벨은
<Suspense>
사용
9-2. 페이지 스트리밍 - loading.tsx
활용
라우트 디렉토리 내에 loading.tsx
생성하면 끝 / Skeleton이나 Spinner 활용
loading.tsx
는 Suspense 기반, 페이지 로드 전까지 fallback UI- SideNav 같은 static 컨텐츠들은 로드 전에도 사용 가능
- 사용자는 로딩 완료까지 기다리지 않아도 됨
라우트 그룹 (라우트 디렉토리 내에 (폴더명)
폴더 설정하여 그룹핑 가능 - URL path에 포함 안되고 해당 그룹만 사용하는 layout.tsx, loading.tsx 사용 가능)
을 활용하여 그룹이 공유하는 loading.tsx
를 만들 수 있다.
9-3. 컴포넌트 스트리밍 - <Suspense>
활용
dynamic 컴포넌트를 Suspense로 감싸준다.
컴포넌트 내에 데이터 fetch 로직이 있어야 한다.
9-4. Suspense 적용시 고려할 점
- stream시 유저의 페이지 경험
- 컨텐츠간 우선순위
- 컴포넌트가 데이터 fetch에 의존하는지
9-5. 스트리밍 방식 별 장단점
- 페이지 스트리밍: 편함 / 컴포넌트 1개가 느린 데이터 fetch를 가지면 전체 페이지 로딩이 길어짐
- 컴포넌트 스트리밍: 페이지 스트리밍의 단점은 해소 / 그러나 UI가 pop되는 등의 문제
상황마다 잘 판단해서 쓰자
10. Partial Prerendering
expermental이므로 스킵
11. 검색과 페이지네이션
11-1. 검색
- 유저가 검색어 입력 후 검색
- URL params 업데이트
- 서버에 데이터 fetch
- UI 리렌더
URL search params 사용하는 이유
- 검색 내용에 대해 URL 즐겨찾기, 공유 가능
- 초기 상태 서버 렌더링 가능
- 사용자 행동 추적, 분석 용이
방법
3가지 훅을 사용
useSearchParams
(url의 search params 객체로 제공 ex:{page: '1', query: 'pending'}
)usePathname
(url path name 리턴 ex:/url
)useRouter
(client 컴포넌트 내에서 navigation)
1) 사용자 input 캡쳐하여 url 업데이트
// search. tsx
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
// 사용자 query 반영하여 url 업데이트
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`); // client side navigation : 서버 새로고침 없음
}
return (
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value); // 사용자 input caputure
}}
defaultValue={searchParams.get('query')?.toString()} // url과 input sync
/>
);
}
2) 데이터 fetch하여 UI 업데이트
// page.tsx
// Page 컴포넌트는 'searchParams'를 prop으로 받는다.
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
return (
<div className="w-full">
<Table query={query} currentPage={currentPage} />
</div>
);
}
// table.tsx
// ...
export default async function Table({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
useSearchParams()
hook vs searchParams
prop
useSearchParams
는 hook이므로 client 컴포넌트에서 사용searchParams
는 server 컴포넌트에서 사용
client에서 searchParams
prop 쓰면 서버 갖다와야 되서 안 좋음
search에 debouncing
input 값 변경될 때마다 서버에 요청을 넣는 건 불필요하다.
사용자 입력 끝나고 검색이 실행되도록 debounce
를 활용하자
// search.tsx
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
11-2. 페이지네이션
검색과 동일한 방식
- 페이지네이션의 페이지 클릭 -> url 업데이트
- 데이터 fetch해와서 ui 업데이트
// page.tsx
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string,
page?: string,
},
}) {
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query); // 전체 page 수 Pagination에 prop으로 넘김
return (
<>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalPages} />
</div>
</>
);
}
'use client';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
// ...
}
11-3. 요약
- client 상태 대신 url search parmas 활용해서 검색, 페이지네이션 구현 가능
- 장점
- 검색 내용에 대해 URL 즐겨찾기, 공유 가능
- 초기 상태 서버 렌더링 가능
- 사용자 행동 추적, 분석 용이
12. Mutating Data
react server actions, server component, form 을 활용하여 data를 변경한다.
12-1. Server Actions?
- Server Actions로 서버에서 비동기 코드를 돌릴 수 있다.
- API 만드는 대신 쓸 수 있다.
- client, server 컴포넌트에서 다 쓸 수 있다.
- 보안에 강점 (POST만 허용, 민감 정보 숨김, input 체크, 에러 메시지 hash, host 제한)
12-2. form과 server actions 활용
<form>
의 action 속성 활용해서 form 입력정보와 함께 action을 실행할 수 있다.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
form 장점: client에서 javascript disabled 되어도 작동한다.
12-3. Next.js와 Server Actions
Server Actions를 사용하여 캐시를 revalidate할 수 있다. (revalidatePath
, revalidateTag
활용)
12-4. Create data
- 사용자 input 캡쳐할 폼 생성
- 서버 액션 생성 / 폼에 바인딩
- 서버 액션에서
formData
의 data 추출 - validate 후 db에 넣을 data 준비
- db에 data 넣기
- 캐시 revalidate하고 목록 페이지로 redirect
1) 사용자 input 캡쳐할 폼 생성
/// create-form.tsx
export default function Form() {
return (
<form>
<Button type="submit">Create Invoice</Button>
</form>
);
}
2) 서버 액션 생성/ 폼에 바인딩
/// actions.ts
'use server';
export async function createInvoice(formData: FormData) {}
/// create-form.tsx
<form action={createInvoice}>
2-1) action
속성?
- HTML에서 action 속성에 URL을 준다. URL은 폼 데이터 전달할 곳으로 보통 API endpoint
- React에서는 다르게 처리
- action 속성에 Server Actions 바인딩
- Server Actions가
POST
API endpoint를 자동 생성 - API endpoint 따로 만들 필요 X
3) 서버 액션에서 formData
의 data 추출
// actions.ts
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// const rawFormData = Object.fromEntries(formData.entries()) // formData에 필드 많을 경우
// Test it out:
console.log(rawFormData);
}
4) validate 후 db에 넣을 data 준비
타입 선언을 해준다.
// definition.ts
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
폼에서 추출한 data를 validate, coercion(변환) 한다.
- 코드로 직접 validate, coercion 로직을 짜거나,
- type validation library (ex: Zod, Joi) 사용한다.
Zod를 사용하면 훨씬 간단하다.
// actions.ts
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
Zod로 선언한 schema는 폼 데이터를 validate, coercion 하는데 쓴다.
// actions.ts
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
}
5) db에 data 넣기
// actions.ts
export async function createInvoice(formData: FormData) {
// 바로 위에 내용
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
6) 캐시 revalidate하고 목록 페이지로 redirect
Next.js에는 client side router cache가 있다. (브라우저에 루트 별로 페이지 캐싱 -> 사용자 빠르게 페이지 이동, 사용자 경험 향상, 서버 부하 감소)
data 업데이트 시에 이 캐시를 revalidatePath
써서 갱신할 수 있다.
// actions.ts
export async function createInvoice(formData: FormData) {
// 바로 위에 내용
revalidatePath('/dashboard/invoices'); // 목록페이지 캐시 갱신
redirect('/dashboard/invoices'); // 목록페이지 redirect
}
12-5. Update data
create과 유사하나 데이터의 id를 db에 전달해야 한다.
- id 들어간 dynamic 루트 생성
- page params에서 id 읽기
- db에서 해당 id 데이터 fetch
- 해당 데이터 담긴 폼 미리 생성
- 폼, 서버 액션 활용해 db 업데이트
1) id 들어간 dynamic 루트 생성
[id]
폴더 명을 []로 감싸서 dynamic 루트 생성 가능하다. (segment 정확하지 않고, data 기반해서 루트 만들고 싶을 때)
ex: 블로그 포스트, 상품 페이지
- invocies
- [id]
- edit
- page.tsx
- edit
- [id]
위 구조로 파일을 생성해준다.
목록의 각 항목에 데이터 수정 버튼을 추가한다.
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
2) page params에서 id 읽기
1)에서 생성한 페이지 내용을 채워준다. form은 create과 유사하나 invoice를 prop으로 받음
Page 컴포넌트는 params
를 prop으로 받을 수 있다. 여기서 루트에 들어간 id를 꺼내서 쓸 수 있다.
// /app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
3) 특정 invoice fetch
id를 가져다가 특정 invoice를 fetch해 온다.
// /app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
이렇게 하면, invoice 수정 용 form을 미리 생성할 수 있다.
4) Server Action에 id 넘기기
<form action={updateInvoice(id)}>
함수 사용하듯이 넘길 수는 없다.
JS bind
를 활용해서 넘긴다.
// /app/ui/invoices/edit-form.tsx
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); // action에 id를 미리 넣어 둔다.
return (
<form action={updateInvoiceWithId}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
그다음 updateInvoice
action을 선언한다.
///app/lib/actions.ts
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
이렇게 update 로직도 구현해 봤다.
12-6. Delete data
delete 로직은 update 로직과 비슷하게 id를 Server Action에 넘겨주고
삭제 버튼을 form으로 감싸주고 action을 바인딩 한다.
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
Server Action도 선언해준다.
// /app/lib/actions.ts
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
13. 에러 처리
JS의 try/catch
와 Next.js API들을 조합해서 에러를 처리한다.
13-1. Server Action에 try/catch
// /app/lib/actions.ts
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
redirect
는 에러를 throw하는 식으로 동작하므로try/catch
밖에 둔다.
13-2. error.tsx
로 에러 처리
각 라우트 별로 error.tsx
파일이 있으면 에러 발생시 fallback으로 보인다.
// /dashboard/invoices/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.tsx
는 client 컴포넌트여야 한다.- props
error
: JS의 nativeError
의 instancereset
: 에러 바운더리 reset / 클릭 시 루트 리렌더
13-3. notFound
함수로 404 에러 처리
notFound
는 존재하지 않는 리소스를 fetch할 때 사용하면 된다.
ex) 존재하지 않는 항목의 상세 페이지 들어갈 때 등
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();
}
// ...
}
notFound
가 호출되면 not-found.tsx
가 루트 내에 있다면 얘를 보여주고 없으면 error.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>
);
}
14. 접근성
- 접근성: 몸이 불편한 사람도 웹 앱을 얼마나 잘 사용할 수 있는지
14-1. ESLint 접근성 plugin
Next.js는 default로 eslint-plugin-jsx-a11y
을 포함한다.
image에 alt
가 없는지, aria-*
와 role
속성을 제대로 줬는지 검사할 수 있다.
next lint
를 스크립트에 추가해주면 Vercel에 배포할 때 build log에 남는다. (Vercel 배포 과정에서 next lint
명령어 실행함)
14-2. 접근성 개선하기
- Semantic HTML: Semantic 태그를 사용하자
- Labelling:
<input>
태그 쓸 때<label>
과htmlFor
속성으로 input의 라벨을 명확히 하자 - Focus Outline: focus 되었을 때 outline을 꼭 주자
14-3. Server-Side form validation
장점
Client-Side form validation과 비교했을 때
- db 수정 전 data validated인지 확인 가능
- client-side validation 꼼수로 넘기고 공격하는 것 방어
- 하나의 source of truth
방법
React의 useFormState
활용
hook 쓰기 때문에 해당 훅 활용 컴포넌트는 client 컴포넌트
type useFormState = (action: Action, initialState: FormState) => [state: FormState, dispatch: Dispatch]
form의 action
속성에 Server 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 schema에 에러와 에러 메시지를 선언해준다.
// /app/lib/action.ts
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(),
});
기존 서버 액션을 2개의 인자를 받도록 변경한다
// 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()
는 에러 발생시 에러를 throw하지 않고 error 필드를 담은 객체를 리턴한다.
함수의 리턴값으로 에러 처리를 할 수 있다.
// /app/lib/actions.ts
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.',
};
}
// ...
}
완성된 서버 액션은 다음과 같다.
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form 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.',
};
}
// Prepare data for insertion into the database
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// Insert data into the database
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// If a database error occurs, return a more specific error.
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
// Revalidate the cache for the invoices page and redirect the user.
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
form 컴포넌트에서 state
를 통해 에러에 접근할 수 있다.
// /app/ui/invoices/create-form.tsx
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<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" /* 접근성 */
>
<option value="" disabled>
Select a customer
</option>
{customers.map((name) => (
<option key={name.id} value={name.id}>
{name.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<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>
</div>
// ...
</div>
</form>
aria-describedby="customer-error"
: 에러 메시지 container와 select 간 관계 형성 - 스크린 리더에서 select창 사용시에 에러 알려준다.id="customer-error"
: 에러 메시지 컨테이너 id,aria-describedby
와 매핑aria-live="polite"
: 에러 메시지 업데이트시 스크린 리더가 말해줌
15. Authentication
- Authentication: 유저 로그인 시 가입된 유저인지 확인
- Authorization: 유저 로그인 했을 때 권한
15-1. 로그인 route 생성
// /app/login/page.tsx
import LoginForm from '@/app/ui/login-form';
export default function LoginPage() {
return (
<main className="flex items-center justify-center md:h-screen">
<LoginForm />
</main>
);
}
15-2. 로그인 로직 구현
NextAuth (사용자 인증 관련 추상화 해놓은 라이브러리)를 사용
1) 환경 설정
먼저, 라이브러리를 설치한다.
npm install next-auth@beta
시크릿 키를 생성해준다.
openssl rand -base64 32
.env
에 시크릿 키 넣어준다.
AUTH_SECRET=your-secret-key
2) pages option 추가
프로젝트 루트에 /auth.config.ts
를 만든다.
// /auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
};
로그인 안 된 유저는 signIn에 준 url로 redirect된다.
3) 로그인 안 된 유저 redirect 설정
Next.js Middleware를 활용한다.
auth.config.ts에 권한 설정 추가
// /auth.config.ts
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;
authorized
콜백은 request가 해당 페이지 접근 가능한지 권한 판별providers
: 다른 로그인 옵션
루트 디렉토리에 middleware.ts
를 만들어 미들웨어를 구성한다.
// /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$).*)'],
};
미들웨어를 사용하면 미인증된 요청은 루트 렌더링도 안한다. 앞 단에서 처리해준다.
보안과 성능상 장점을 가진다.
4) 로그인 로직
환경 설정을 위해 auth.ts
를 만든다.
// auth.ts
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({})], // Credentials({}): 사용자 ID, 비번으로 로그인 가능하게 함 / github, google, 카카오 로그인 옵션 추가 가능
});
예제는 Credentials
를 썼지만 실무에서는 OAuth
나 email
provider를 사용하자
zod로 id, 비번 유효성 검사를 하자
// auth.ts
import { z } from 'zod';
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);
},
}),
],
});
유저가 존재하는지 DB에 조회한다.
- 보안 위해 비밀번호는 hash -
bcrypt
라이브러리 사용한다. (Node 기반이기 때문에 Next.js 미들웨어와 파일을 분리해 준다.)
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { sql } from '@vercel/postgres';
import { z } from 'zod';
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) {
// ...
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;
}
console.log('Invalid credentials');
return null;
},
}),
],
});
5) 로그인 form 업데이트
먼저 서버 액션을 만들어 준다.
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;
}
}
useFormState
활용해서 서버액션과 폼을 연결한다.
// /app/ui/login-form.tsx
'use client';
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>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
Email
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder="Enter your email address"
required
/>
<AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
Password
</label>
<div className="relative">
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder="Enter password"
required
minLength={6}
/>
<KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</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>
);
}
15-3. 로그아웃 로직 구현
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">
// ...
<form
action={async () => {
'use server';
await signOut();
}}
>
<button>
Sign Out
</button>
</form>
</div>
);
}
16. Metadata
SEO 위해 Metadata를 추가한다.
16-1. Metadata?
페이지에 대한 정보를 제공하는 데이터
사용자에 보여지지 않지만. HTML의 <head>
안에 있다.
검색엔진이 사용한다. 즉, Metadata를 잘 구성해야 SEO에 유리하다.
종류
Title Metadata: 탭에 보이는 제목, SEO에 핵심
<title>Page Title</title>
Description Metadata: 웹 페이지 내용 설명, 검색 엔진 결과에 자주 보여짐
<meta name="description" content="A brief description of the page content." />
Keyword Metadata: 웹 페이지 내용 관련 키워드, 검색 엔진이 index할 때 사용
<meta name="keywords" content="keyword1, keyword2, keyword3" />
Open Graph Metadata: SNS에 공유될 때 사용, title, descripton, preview image 등
<meta property="og:title" content="Title Here" />
<meta property="og:description" content="Description Here" />
<meta property="og:image" content="image_url_here" />
Favicon Metadata: 웹 페이지 아이콘, 탭 제목 왼쪽
<link rel="icon" href="path/to/favicon.ico" />
16-2. Metadata 추가
Next.js는 2가지 방법을 제공
- Config-based:
layout.tsx
나page.tsx
에서metadata
object export 하거나generateMetadata()
사용 - File-based: 파일로 metadata추가
favicon.ico
,apple-icon.jpg
,icon.jpg
: 아이콘opengraph-image.jpg
,twitter-image.jpg
: SNS 공유될 때 이미지robots.txt
: 검색 엔진 크롤링에 지침 제공sitemap.xml
: 웹사이트 구조 정보 제공
1) Favicon, OG image
/app
디렉토리에 favicon.ico
, opengraph-image.jpg
놓으면 Next.js가 알아서 head에 넣어줌
2) title, descriptions
각 라우트 별 layout.tsx
나 page.tsx
에 따로 넣어줄 수 있다.
루트에서 넣어주면 모든 페이지 적용 - 라우트 별 metadata 선언하면 라우트 별 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() {
// ...
}
템플릿도 사용할 수 있다. ex) 상품명, 회사명
// /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'),
};
%s
는 각 page 별 title이 들어간다.