프론트엔드/Next.js

Next.js 14 기초 활용법

정현우12 2024. 2. 24. 21:47

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.tsxpage.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 페이지에 있는지 표시해주기 위해, 다음과 같은 패턴을 활용할 수 있다.

  1. next/navigationusePathname 훅을 활용하여 현재 url의 pathname을 뽑아낸다.
  2. <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

  1. API Layer
    • API 제공하는 써드 파티 서비스 사용 시
    • 클라이언트 단에서 데이터 fetch 시
    Next.js의 Route Handlers 활용하여 API 생성 가능
  2. DB 쿼리
    • 서버 컴포넌트 사용 시
      • DB 시크릿 클라이언트 단에 노출 없이 DB에 쿼리를 던질 수 있다.
  3. 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. 검색

  1. 유저가 검색어 입력 후 검색
  2. URL params 업데이트
  3. 서버에 데이터 fetch
  4. 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

  1. 사용자 input 캡쳐할 폼 생성
  2. 서버 액션 생성 / 폼에 바인딩
  3. 서버 액션에서 formData 의 data 추출
  4. validate 후 db에 넣을 data 준비
  5. db에 data 넣기
  6. 캐시 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에 전달해야 한다.

  1. id 들어간 dynamic 루트 생성
  2. page params에서 id 읽기
  3. db에서 해당 id 데이터 fetch
  4. 해당 데이터 담긴 폼 미리 생성
  5. 폼, 서버 액션 활용해 db 업데이트

1) id 들어간 dynamic 루트 생성

[id] 폴더 명을 []로 감싸서 dynamic 루트 생성 가능하다. (segment 정확하지 않고, data 기반해서 루트 만들고 싶을 때)

ex: 블로그 포스트, 상품 페이지

  • invocies
    • [id]
      • edit
        • page.tsx

위 구조로 파일을 생성해준다.

목록의 각 항목에 데이터 수정 버튼을 추가한다.

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의 native Error 의 instance
    • reset: 에러 바운더리 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를 썼지만 실무에서는 OAuthemail 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.tsxpage.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.tsxpage.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이 들어간다.

17. 참고할 만한 자료

https://nextjs.org/learn/dashboard-app/next-steps