프론트엔드 첫걸음

nextjs learn 정리 본문

개발 공부/next.js

nextjs learn 정리

차정 2024. 2. 5. 15:11

글꼴 및 이미지 최적화

글꼴

  • Next.js는 빌드 시 글꼴 파일을 다운로드하고 이를 다른 정적 자산과 함께 호스팅
    -> 글꼴에 대한 추가 네트워크 요청이 없음 -> 성능향상

  • 웹폰트 사용은 Layout shift 생길 수 있음

  • fonts.ts

    //next/font/google module 에서  폰트 가져옴
    import { Inter, Lusitana  } from 'next/font/google';
    
    // 로드할 Inter의 하위집합 latin 
    export const inter = Inter({ subsets: ['latin'] });
    
    //  서브 폰트
    export const lusitana = Lusitana({
    weight: ['400', '700'],
    subsets: ['latin'],
    });
    
  • 메인폰트사용 - layout.tsx

        <body className={`${inter.className} antialiased`}>{children}</body>
  • 서브폰트사용 - page.tsx

       <p className={`${lusitana.className} text-xl text-gray-800 md:text-3xl md:leading-normal`}>

    이미지

  • Image 컴포넌트의 이미지 최적화

    • 이미지가 로드될 때 레이아웃이 자동으로 이동하는 것을 방지합니다.
    • 뷰포트가 작은 기기에 큰 이미지가 전송되지 않도록 이미지 크기 조정.
    • 기본적으로 이미지 지연 로딩(이미지가 뷰포트에 들어갈 때 이미지가 로드됨).
    • 브라우저에서 WebP 및 AVIF와 같은 최신 형식을 지원하는 경우 이미지를 제공합니다.
  • Layout shift막기위해, width, height 사용할때는 소스이미지와 가로세로 비율이 동일해야함

페이지 간 탐색

  • Link로 페이지 이동시 페이지 전체 새로고침이 없다.
  • 자동 코드 분할 및 프리페칭
    • 탐색 환경을 개선하기 위해 Next.js는 경로 세그먼트별로 애플리케이션을 자동으로 코드 분할 합니다.
    • 이는 브라우저가 초기 로드 시 모든 애플리케이션 코드를 로드하는 기존 React SPA와는 다릅니다.
    • 경로별로 코드를 분할 = 페이지가 격리. 특정 페이지에서 오류가 발생해도 나머지 애플리케이션은 계속 작동
    • 또한 프로덕션 환경에서 컴포넌트가 브라우저의 뷰포트에 표시될 때마다 Next.js는 백그라운드에서 링크된 경로에 대한 코드를 자동으로 prefetch합니다.
    • 사용자가 링크를 클릭할 때쯤이면 목적지 페이지의 코드가 이미 백그라운드에서 로드되어 있으므로 페이지 전환이 거의 즉각적으로 이루어집니다!

DB 가져오기

data fetching

  • api 계층
    • 클라이언트에서 데이터 가져오는 경우 Db정보가 노출되지 않도록 api계층이 필요하다
    • next.js는 라우터 핸들러 사용하여 API 엔드포인트 만들 수 있다.
  • DB query
    • api 엔드포인트 만들때
    • 서버컴포넌트에서 서버에서 데이터 불러올때 api계층 건너뛰고 DB직접 쿼리할 수 있다.

request waterfall

  • 이전 요청의 완료 여부에 따라 달라지는 일련의 네트워크 요청을 의미합니다.
  • 데이터 가져오기의 경우, 각 요청은 이전 요청이 데이터를 반환한 후에만 시작할 수 있습니다.

    request waterfall 방지

  • 모든 데이터 요청 동시에 병렬로 진행
  • Promise.all() 또는 Promise.allSettled() 사용
    • 하지만 하나의 데이터가 유독 느린경우에는 문제가 됨.

정적 및 동적 렌더링

  • 정적렌더링
    • 빌드시 렌더링 , 결과를 cdn에 배포하고 캐시할수있음
    • 장점
      • 더 빠른 웹사이트(미리 렌더링된 콘텐츠 캐시하여 전세계 배포~ 전세계에서 빠르고 안정적으로 접근가능)
      • 서버부하 감소(콘텐츠 캐시~ 사용자요청에 따라 동적으로 콘텐츠 생성할 필요x)
      • SEO 향상
    • 정적 블로그 게시물이나 제품 페이지와 같이 사용자 간에 공유되는 데이터나 데이터가 없는 UI에 유용합니다.
  • 동적렌더링
    • request시 렌더링 .
    • 장점
      • 실시간 데이터 -
      • 사용자별 콘텐츠
      • Request time information - 요청시점에만 알수있는 정보 제공가능

스트리밍

  • 하나의 데이터가 유독 느릴때 전체 페이지 렌더가 늦어지는 상황이 발생함 => 스트리밍 사용.

  • 스트리밍이란?

    • 스트리밍은 route를 더 작은 "chunks"로 나누고 준비가 되면 서버에서 클라이언트로 점진적으로 스트리밍할 수 있는 데이터 전송 기술입니다.
    • 스트리밍하면 느린 데이터 요청이 전체 페이지를 차단하는 것을 방지할 수 있습니다. 이를 통해 사용자는 UI가 사용자에게 표시되기 전에 모든 데이터가 로드될 때까지 기다리지 않고 페이지의 일부를 보고 상호 작용할 수 있습니다.
    • 스트리밍이 하나의 청크로 간주되기때문에 리액트 컴포넌트와 잘 작동함.
  • next.js에서 스트리밍 사용방법

    • loading.tsx추가
      • Loading이란 이름으로 컴포넌트 만들기만 하면 로딩중에 자동으로 이걸 보여줌( 페이지 콘텐츠가 로드되는 동안 대체 UI로 표시할 폴백 UI를 생성할 수 있습니다.)
    • Suspense 컴포넌트 사용
  • 데이터를 받는 기능은 데이터가 필요한 컴포넌트로 옮기고 suspend 컴퍼넌트에 fallback으로 로딩 중 보여질 스켈레톤 컴포넌트를 전달

     <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense> 

Search and Pagination

Why use URL search params?

  • URL 매개변수를 사용하여 검색을 구현하면 다음과 같은 몇 가지 이점이 있습니다.
    • 북마크 가능 및 공유 가능 URL : 검색 매개변수가 URL에 있으므로 사용자는 향후 참조 또는 공유를 위해 검색어 및 필터를 포함하여 애플리케이션의 현재 상태를 북마크에 추가할 수 있습니다.
    • 서버 측 렌더링 및 초기 로드 : URL 매개변수를 서버에서 직접 사용하여 초기 상태를 렌더링할 수 있으므로 서버 렌더링을 더 쉽게 처리할 수 있습니다.
    • 분석 및 추적 : URL에 직접 검색어와 필터가 있으면 추가 클라이언트 측 논리 없이도 사용자 행동을 더 쉽게 추적할 수 있습니다.

      URL search params 추출하기 위한 방법

  • 서버컴포넌트- searchParams을 prop으로 넘긴다.
  • 클라이언트 컴포넌트 - useSearchParams를 사용한다.
    //서버컴포넌트
    export default async function Page({
    searchParams,
    }: {
    searchParams?: {
    query?: string;
    page?: string;
    };
    }) {
    const query = searchParams?.query || '';
    const currentPage = Number(searchParams?.page) || 1;
    }
    //중략
    //클라이언트 컴포넌트
    import { useSearchParams, usePathname, useRouter } from 'next/navigation';
    

export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const {replace} = useRouter();
//중략
}

### 검색 관련 clienthook
- useSearchParams 
  - 현재 URL의 매개변수에 액세스할 수 있습니다. 
  - 예를 들어, 이 URL '/dashboard/invoices?page=1&query=pending'에 대한 useSearchParams는 다음과 같습니다: {page: '1', query: 'pending'}.
- usePathname 
  - 현재 URL의 경로 이름을 읽을 수 있습니다. 
  - 예를 들어 /dashboard/invoices 경로의 경우, 사용 경로명은 '/dashboard/invoices'를 반환합니다.
- useRouter 
  - 클라이언트 구성 요소 내에서 프로그래밍 방식으로 경로 간 탐색을 활성화합니다. 여러 가지 방법을 사용할 수 있습니다.

### Debouncing
- 디바운싱이란
  - 함수가 실행될 수 있는 속도를 제한하는 프로그래밍 방식입니다. 
  - 사용자가 입력을 중단한 경우에만 데이터베이스를 쿼리할 때 사용
- 디바운싱 작동 방식:
    - Trigger Event : 디바운싱되어야 하는 이벤트(검색창의 키 입력 등)가 발생하면 타이머가 시작됩니다.
    - 대기 : 타이머가 만료되기 전에 새로운 이벤트가 발생하면 타이머가 재설정됩니다.
    - 실행 : 타이머가 카운트다운 끝에 도달하면 디바운싱된 함수가 실행됩니다.
- 예시
```javascript
import { useDebouncedCallback } from 'use-debounce';

// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
  console.log(`Searching... ${term}`);

  const params = new URLSearchParams(searchParams);
  if (term) {
    params.set('query', term);
  } else {
    params.delete('query');
  }
  replace(`${pathname}?${params.toString()}`);
}, 300);

Pagination

  • invoices 페이지에서 전체 인보이스 검색후 전체 페이지 수를 Pagination 컴포넌트에 전달
  • Pagination에서 각 페이지에 따른 url 생성-> 페이지 버튼에 link href로 전달 -> 각 페이지버튼 클릭시 page 연결
    //page url 생성 함수
    const createPageURL = (pageNumber: number | string) => {
      const params = new URLSearchParams(searchParams);
      params.set('page', pageNumber.toString());
      return `${pathname}?${params.toString()}`;
    };
    // 중략
    <PaginationNumber
    key={page}
    href={createPageURL(page)}
    page={page}
    position={position}
    isActive={currentPage === page}
    />

Mutating Data

Server Actions?

  • 서버에서 직접 비동기 코드를 실행.
  • 데이터 변경 위해 API 엔드포인트 생성할필요 없음
  • 보안에 효과적
  • Next.js 캐싱과도 긴밀하게 통합 -> 서버 액션을 통해 양식이 제출되면 해당 액션을 사용하여 데이터를 변경할 수 있을 뿐만 아니라 revalidatePath 및 revalidateTag와 같은 API를 사용하여 관련 캐시의 유효성을 다시 검사할 수도 있습니다.

    Using forms with Server Actions

// 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>;
}

Create a Server Action

  • 파일 최상단에 'use server'
  • 파일 내에서 내보낸 모든 함수를 서버 함수로 표시할 수 있습니다.
  • 서버 함수를 클라이언트 및 서버 컴포넌트로 가져올 수 있으므로 매우 다양하게 활용할 수 있습니다.
    'use server';
    export async function createInvoice(formData: FormData) {
    // formData에서 데이터 추출
    const rawFormData = Object.fromEntries(formData.entries())
    }

    revalidatePath

  • Next.js의 클라이언트 측 라우터 캐시가 사용자 브라우저에 일정 시간 동안 경로 세그먼트를 저장
  • 이 캐시는 프리페칭과 함께 사용자가 서버에 대한 요청 횟수를 줄이면서 경로를 빠르게 탐색할 수 있도록 해줍니다.
  • 데이터 업데이트 시 이 캐시를 지우고 서버에 대한 새 요청을 트리거해야한다.
  • Next.js의 revalidatePath함수를 사용하면 캐시 지우고 새로 요청
  • 요청후 redirect함수로 리다이렉트
    'use server';
    

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

// ...

export async function createInvoice(formData: FormData) {
// ...

revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}


## Handling Errors
### not Found
[http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit]
- DB에 존재하지 않는 uuid 로 접속했을때 not Found 페이지로 연결됨
- not-found.tsx가 error.tsx보다 우선순위가 높아 구체적인 오류 처리할때 사용가능
```javascript
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();
  }

  // ...
}

Improving Accessibility

Form 접근성 개선

  • Sementic HTML
    • < div > 대신 시맨틱 요소(< input >, < option > 등)를 사용.
  • Labelling
    • < label > 과 htmlFor 사용 이렇게 하면 컨텍스트를 제공하여 AT 지원이 향상되고 사용자가 label을 클릭하여 해당 input필드에 초점을 맞출 수 있어 사용성이 향상됩니다.
  • Focus Outline
    • 필드가 초점이 맞춰져 있을 때 윤곽선이 표시

Form validation

client-side validation

  • input , select요소에 required 속성 추가

    server-side validation

  • 고급 서버 측 유효성 검사를 위해 zod와 같은 라이브러리를 사용하여 데이터를 변경하기 전에 양식 필드의 유효성 검사가능
    • DB에 데이터 보내기전에 예상된 형식인지 확인가능
    • 악의적인 사용자가 클라이언트 측 유효성 검사를 우회하는 위험감소.
    • 유효한 데이터로 간주되는 정보에 대한 하나의 진실 소스를 확보.
      'use server'
      

import { z } from 'zod'

const schema = z.object({
email: z.string({
invalid_type_error: 'Invalid Email',
}),
})

export default async function createUser(formData: FormData) {
// zod 이용하여 검증
const validatedFields = schema.safeParse({
email: formData.get('email'),
})

// form data가 유효하지 않으면 바로 return
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}

// formData의 validation 확인한 후에 Mutate data

}

- safeFarse는 sussess, errors포함한 객체를 반환해서, validation을 try catch 없이 진행할수 있게 한다.
    - safeParse해서 검증되면, 검증된 filed의 데이타로뽑아냄..

- 서버에서 필드의 유효성을 검사하고 나면 액션에서 직렬화 가능한 객체를 반환하고 React useFormState 훅을 사용하여 사용자에게 메시지를 표시할 수 있습니다.
- 액션을 useFormState에 전달하면 액션의 함수 시그니처가 변경되어 첫 번째 인수로 새로운 prevState 또는 initialState 매개변수를 받습니다.
- useFormState는 React 훅이므로 클라이언트 컴포넌트에서 사용해야 합니다.

#### useFormState
- (action, initialState)를 받아  state, dispatch를 반환함.
```javascript
const [state, dispatch] = useFormState(createInvoice, initialState);
  • 서버 액션과 함께 사용하는 경우, useFormState를 사용하면 하이드레이션이 완료되기 전에도 Form 제출에 대한 서버의 응답을 표시할 수 있음.
    • state : form submit전에는 initial State , submit후에는 제공한 action(createInvoice)에 의한 return값
    • dispatch : form 요소에 action 으로 전달할 새로운 action함수 .
      < form action={ dispatch }>  
  • form의 action 속성에 dispatch 주입함.
  • form이 submit되면 action함수(createInvoice)가 호출되어 state(currentState)를 반환함.
import { useFormState } from 'react-dom';

export default function Form({ customers }: { customers: CustomerField[] }) {
 // 초기값은 message , errors를 속성으로 하는 객체
  const initialState = { message: null, errors: {} };
  // createInvoice 액션을 인수로 전달
  const [state, dispatch] = useFormState(createInvoice, initialState);


  // return <form action={createInvoice}>...</form>;
  return <form action={dispatch}>...</form>;
}
    // 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) {
      // Validate form fields using Zod
      // safeParse()는 성공 또는 오류 필드를 포함하는 객체를 반환합니다. 이렇게 하면 이 논리를 try/catch 블록 안에 넣지 않고도 유효성 검사를 보다 원활하게 처리하는 데 도움이 됩니다.
      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.',
        };
      }

      // mutating data
    }

aria-describedby

- aria-describedby 속성은 문자열로 구성된 고유한 id 값을 가져야 한다.
- 해당 Id 값은 추가 정보를 제공하는 id 속성과 일치해야 한다.
- 일반적으로 aria-describedby 속성을 사용하여 스크린 리더가 해당 요소에 대한 설명을 읽을 수 있도록 한다.
<select
  id="customer"
  name="customerId"
  className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  defaultValue=""
  aria-describedby="customer-error"
>

<!--중략-->
  <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>
  • 아이디 customer-error 갖는 에러설명 영역이 select 요소를 설명함. select에서 오류났을때 스크린리더가 customer-error에 있는 부분을 읽음

    aria live

  • 스크린리더가 페이지의 업데이트에 대해 알림
  • aria-live="off"
    • 실시간 업데이트 사용하지 않음
  • aria-live="assertive"
    • 변경 사항이 발생하면 가능한 빨리 읽음
  • aria-live="polite"
    • 변경사항 발생하면 사용자가 하던 작업이 끝나면 (사용자 활동이 없을때) 읽음

Authentication

Authentication vs. Authorization

  • Authentication is about making sure the user is who they say they are. You're proving your identity with something you have like a username and password.
  • Authorization is the next step. Once a user's identity is confirmed, authorization decides what parts of the application they are allowed to use

    NextAuth.js

  • 세션관리, 로그인-로그아웃,다른 인증관련 부분 포함한 복잡한 부분을 추상화함.
  • (직접 구현하기에 오래걸리고, 에러나기쉬운) 인증 관한 통합 솔루션을 제공.

    설치

  1. 라이브러리 설치
    npm install next-auth@beta
  2. 암호화키 생성 및 보관
    • 쿠키를 암호화하여 사용자 세션의 보안을 보장
      openssl rand -base64 32
  • env파일에 AUTH_SECRET라는 변수로 저장
    AUTH_SECRET=your-secret-key
  1. 페이지 옵션 추가
    //auth.config.ts
    import type { NextAuthConfig } from 'next-auth';
    

export const authConfig = {
pages: {
signIn: '/login',
},
};

- 인증 설정에 있는 pages설정을 통해 login, logout, error페이지의 라우트 설정할수있다.
- 필수는 아니지만 이렇게 signIn에 '/login' 추가해주면 nextAuth 기본페이지 대신 자신이 설정한 login 페이지 볼수있음
4. middleware 설정
#### middleware.ts
```javascript
// 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$).*)'], //이러한 경로에서 사용됨.
};
  • 미들웨어에서 authConfig 객체를 사용해서 NextAuth.js를 초기화하고 인증 속성을 내보냄.

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 콜백
    • nextjs middleware로 검증되었는지 확인.
    • login 안한상태에서 접근하는것 방지
    • 다른요청보다 먼저 call되고, auth와 request 속성가진 객체 받음
    • auth 속성은 사용자 세션을 포함하고, request는 들어온 request를 포함함.
  • provider는 로그인 옵션 (google, github 등 )
  1. Credentials provider
  • The Credentials provider allows users to log in with a username and a password.
  • Credentials provider 사용하더라도 대체할 OAuth나 email provider를 제공하는 것이 좋다.
    //auth.ts
    import NextAuth from 'next-auth';
    import Credentials from 'next-auth/providers/credentials';
    import { authConfig } from './auth.config';
    import { z } from 'zod';
    import { sql } from '@vercel/postgres';
    import type { User } from '@/app/lib/definitions';
    import bcrypt from 'bcrypt';
    

// db에서 user 검색해오기
async function getUser(email: string): Promise<User | undefined> {
try {
const user = await sqlSELECT * 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({
// authentication 로직을 다루기위해 authorize 함수사용.
async authorize(credentials) {
// zod 로 검증
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;//패스워드와 일치하면 user 반환 
    }

    return null; 
  },
}),

],
});

6. Login 
- action 함수 생성
```javascript
//action.ts
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에 useFormState사용해서 서버액션 콜, error 핸들 추가 기능 추가
    import { useFormState, useFormStatus } from 'react-dom';
    import { authenticate } from '@/app/lib/actions';
    

export default function LoginForm() {
const [errorMessage, dispatch] = useFormState(authenticate, undefined);

return (


//중략

{errorMessage && (
<>

{errorMessage}


</>
)}

// 중략

</form>

)}

- useFormState사용해서 LoginButton 펜딩 
```javascript
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>
  );
}
  • aria-disabled = true 이면 비활성.
  • pending일때는 로그인버튼 비활성이다가 로그인 상태 pending 아니면 활성(버튼 클릭 가능)
  1. logout
    ///ui/dashboard/sidenav.tsx
    import { signOut } from '@/auth';
    <form
    action={async () => {
     'use server';
     await signOut();
    }}
    >

Metadata

  • 웹페이지에 대한 추가 세부정보를 제공
  • SEO를 향상
  • layout.tsx에서 반복되는 페이지에 대한 메타데이터 설정 가능
    //layout.tsx
    import { Metadata } from 'next';
    export const metadata: Metadata = {
    title: {
      template: '%s | Acme Dashboard', // %s가 특정 페이지 제목으로 대체됨.
      default: 'Acme Dashboard',
    },
    description: 'The official Next.js Learn Dashboard built with App Router.',
    metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
    };
    //중략