[Next] ๐Ÿ’ Next ์‚ฌ์šฉ๋ฒ• (v14)

TATAยท2024๋…„ 1์›” 20์ผ
0

Next.js

๋ชฉ๋ก ๋ณด๊ธฐ
1/1

โ–ท Next.js

Next.js๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง(SSR)์„ ์ง€์›ํ•œ๋‹ค.
๋•Œ๋ฌธ์— ์ดˆ๊ธฐ ๋ Œ๋”๋ง ์†๋„์™€ ๊ฒ€์ƒ‰ ์—”์ง„ ์ตœ์ ํ™”(SEO)๋ฅผ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ๋‹ค.

๋˜ํ•œ ํŽ˜์ด์ง€ ์ด๋™ ์‹œ ๊นœ๋นก์ž„์ด ์—†๋‹ค.(CSR)
๋นŒ๋“œ ์‹œ์ ์— ํŽ˜์ด์ง€๋ฅผ ์‚ฌ์ „์— ์ƒ์„ฑํ•˜์—ฌ ์ •์ ์ธ HTMLํŒŒ์ผ๋กœ ์ œ๊ณตํ•œ๋‹ค.(SSG)

์ฆ‰, Next.js๋Š” ๋น ๋ฅธ ๋ผ์šฐํŒ…๊ณผ ์ ์€ ๋„คํŠธ์›Œํฌ๋ฅผ ์ง€์›ํ•œ๋‹ค๋Š” ๊ฒƒ.


๐Ÿ’  ์„ค์น˜

# ์„ค์น˜
npx create-next-app@latest --typescript

# ์‹คํ–‰
npm run dev

# ํ”„๋ฆฌํ‹ฐ์–ด ์„ค์น˜
npm install --save-dev --save-exact prettier
npm install --save-dev eslint-config-prettier

๐Ÿ’  layout

์—ฌ๋Ÿฌ ํŽ˜์ด์ง€์—์„œ ๊ณต์œ ๋˜๋Š” ์ค‘์ฒฉ ๊ฐ€๋Šฅํ•œ ๋ ˆ์ด์•„์›ƒ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค.

โž• loading ๋กœ๋”ฉํŽ˜์ด์ง€ - loading.tsx


๐Ÿ’  template

template


๐Ÿ’  not-found

not-found.tsx

/* app/profile/not-found.tsx */
const ProfileNotFound = () => {
  return <div>Not Found</div>
}

export default ProfileNotFound;

next/navigation

/* app/profile/page.tsx */
import { notFound } from "next/navigation"

const ProfilePage = ({ params: { params: { id: number } } }) => {
  if (params.id > 100) notFound();
  
  return <Profile />
}

export default ProfilePage;

๐Ÿ’  error

error

error.tsx

/* app/profile/error.tsx */
"use client"

interface Props {
  error: Error;
  reset: () => void;
}

const ProfileErrorPage = ({ error, reset } : Props) => {
  console.log('error:', error)
  
  return (
  	<div>์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</div>
    <button onClick={() => reset()}>reset</button>
  )
}

export default ProfileErrorPage;

(app/layout.tsx์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์˜ค๋ฅ˜๋Š” global-error.tsx ํŒŒ์ผ์„ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.)


๐Ÿ’  Routing

Next.js๋Š” appํด๋”๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋ผ์šฐํŒ…๋œ๋‹ค.
(src/app/page.tsx๊ฐ€ "/" ๋ฉ”์ธ ๊ฒฝ๋กœ์ž„)

/ โ†’ app/page.tsx
/about โ†’ app/about/page.tsx


๊ฒฝ๋กœ ์ด๋™์ด ๊ฐ€๋Šฅํ•˜๋‹ค.

import Link from 'next/link'
 
export default function Page() {
  return <Link href="/dashboard">Dashboard</Link>
}

๐Ÿ’  useRouter

router.push(href): ๊ฒฝ๋กœ ์ด๋™ (๋ธŒ๋ผ์šฐ์ € ๊ธฐ๋ก์— ์ƒˆ ํ•ญ๋ชฉ ์ถ”๊ฐ€)
router.replace(href): ๊ฒฝ๋กœ ์ด๋™ (๋ธŒ๋ผ์šฐ์ € ๊ธฐ๋ก์— ์ƒˆ ํ•ญ๋ชฉ ์ถ”๊ฐ€ ์•ˆํ•จ)
router.refresh(): ํ˜„์žฌ ๊ฒฝ๋กœ๋ฅผ ์ƒˆ๋กœ ๊ณ ์นจ
router.prefetch(href): ๋” ๋น ๋ฅธ ํด๋ผ์ด์–ธํŠธ ์ธก ์ „ํ™˜์„ ์œ„ํ•ด ์ œ๊ณต๋œ ๊ฒฝ๋กœ๋ฅผ ๋ฏธ๋ฆฌ ๊ฐ€์ ธ์˜ด
router.back(): ์ด์ „ ๊ฒฝ๋กœ๋กœ ์ด๋™
router.forward(): ๋‹ค์Œ ํŽ˜์ด์ง€๋กœ ์ด๋™

/* useRouter ์‚ฌ์šฉ ์˜ˆ์‹œ */
import { useRouter } from 'next/navigation'
 
export default function Page() {
  const router = useRouter();
  const { id } = router.query; // useParams์™€ ๊ฐ™์€ ์—ญํ• ์„ ํ•จ.
 
  return (
    <button type="button" onClick={() => router.push('/dashboard')}>
      Dashboard
    </button>
  )
}

๐Ÿ’  Catch All Segments

ํ•œ ๊ฐœ ์ด์ƒ์˜ ๋งค๊ฐœ๋ณ€์ˆ˜๊ฐ€ ์žˆ์„ ๋•Œ ๋ Œ๋”๋ง ๋œ๋‹ค.

/profile/[...username]/page.tsx

๋งค๊ฐœ๋ณ€์ˆ˜ 1๊ฐœ - /profile/tata
๋งค๊ฐœ๋ณ€์ˆ˜ 2๊ฐœ - /profile/tata/post1
๋งค๊ฐœ๋ณ€์ˆ˜ 3๊ฐœ - /profile/tata/setting/security

(/profile๋„ ๋ Œ๋”๋ง ๋˜๊ฒŒ ํ•˜๊ณ  ์‹ถ๋‹ค๋ฉด ๋Œ€๊ด„ํ˜ธ ๋‘์Œ์œผ๋กœ)
/profile/[[...username]]/page.tsx


๐Ÿ’  generateStaticParams

12๋ฒ„์ „์—์„œ๋Š” getStaticPaths์™€ getStaticProps์„ ์‚ฌ์šฉํ•ด์„œ ๋™์  ๊ฒฝ๋กœ๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด, 13๋ฒ„์ „์˜ app ๋ผ์šฐํ„ฐ์—์„œ๋Š” generateStaticParams์„ ์‚ฌ์šฉํ•˜์—ฌ ๋™์  ๊ฒฝ๋กœ๋ฅผ ๋งŒ๋“ค๋ฉด ๋œ๋‹ค.

app ๋ผ์šฐํ„ฐ์—์„œ๋Š” getStaticProps๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋‹ˆ params์— ๋งž์ถฐ์„œ ๋ฐ์ดํ„ฐ๋ฅผ fetch ํ•ด์˜ค๋ฉด ๋จ.

/* app/blog/[slug]/page.js */
export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then((res) => res.json())
 
  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default function Page({ params }) {
  const { slug } = params
  ...
}

๋Œ€์‹ , fetch ์˜ต์…˜ ๊ฐ์ฒด๋ฅผ ํ™•์žฅํ•ด์„œ ๊ฐ ์š”์ฒญ์— ๋Œ€ํ•œ ์บ์‹ฑ ๋ฐ ์žฌ๊ฒ€์ฆ์„ ์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค.

fetch ์˜ต์…˜
{ next: { revalidate: 3600 } : ์ผ์ • ๊ฐ„๊ฒฉ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์žฌ๊ฒ€์ฆ(revalidate๋กœ ์ •ํ•ด์ค€ ์‹œ๊ฐ„ ์•ˆ์— ์ƒˆ๋กœ์šด ์š”์ฒญ์ด ์˜ค๋ฉด ๋ฐ์ดํ„ฐ ์—…๋ฐ์ดํŠธ)
{ cache: 'force-cache' } : ์บ์‹œ๋ฅผ ๊ฐ•์ œ, ์ •์ ์ธ html์„ ๋ฏธ๋ฆฌ ์ƒ์„ฑ (ssg...๊ธฐ์กด์˜ getStaticProps)
{ cache: 'no-store' } : ์š”์ฒญ์ด ์žˆ์„ ๋•Œ๋งˆ๋‹ค ๋ฐ์ดํ„ฐ๋ฅผ ๋™์ ์œผ๋กœ ๊ฐ€์ ธ์˜จ๋‹ค.(ssr...๊ธฐ์กด์˜ getServerSideProps)


๐Ÿ’  dynamic import

์ปดํฌ๋„ŒํŠธ๋ฅผ ๋™์ ์œผ๋กœ ๊ฐ€์ ธ์™€์„œ ํ•„์š”ํ•œ ์‹œ์ ์— ๋ Œ๋”๋งํ•œ๋‹ค.

ssr: ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง(SSR)์—์„œ ์ œ์™ธ ํ•  ์ˆ˜ ์žˆ๋Š” ์˜ต์…˜

import dynamic from 'next/dynamic';

// ๋™์ ์œผ๋กœ ๊ฐ€์ ธ์˜ฌ ์ปดํฌ๋„ŒํŠธ
const DynamicComponent = dynamic(() => import('../components/DynamicComponent'), {
  loading: () => <div>...loading</div>, // ๋กœ๋”ฉ
  ssr: false, // ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ๋ฅผ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง์—์„œ ์ œ์™ธ
});

๐Ÿ’  next/image

next/image

์ด๋ฏธ์ง€๋ฅผ webp type์œผ๋กœ ์šฉ๋Ÿ‰ ์ตœ์ ํ™”๋ฅผ ํ•ด์ค€๋‹ค.

import Image from "next/image";
import example from "../../public/example.jpg";

<figure>
  <figcaption>์˜ˆ์‹œ ์ด๋ฏธ์ง€</figcaption>
  <Image
    src={example}
    alt="์˜ˆ์‹œ ์ด๋ฏธ์ง€"
    width={500}
    height={500}
    quality={100} {/* ์ด๋ฏธ์ง€๋ฅผ ์–ผ๋งˆ๋‚˜ ์••์ถ•ํ• ๊ฑด์ง€. ๊ธฐ๋ณธ๊ฐ’์€ 75์ž„. */}
    placeholder="blur" {/* ์ด๋ฏธ์ง€ ๋กœ๋”ฉ ์‹œ ๋ธ”๋Ÿฌ ์ฒ˜๋ฆฌ๋œ ์ด๋ฏธ์ง€๋ฅผ ๋ณด์—ฌ์คŒ */}
    blurDataURL="" {/* ๋กœ์ปฌ ์ด๋ฏธ์ง€๊ฐ€ ์•„๋‹ˆ๋ฉด์„œ placeholder์†์„ฑ์„ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ ์ถ”๊ฐ€ํ•ด์•ผ ํ•จ */}
  />
</figure>
<Image sizes="(max-width: 768px) 100%, (min-width: 768px) 1920px 1000px" src={section1Bg} className="object-cover" alt="๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€" placeholder="blur" />

๋ฐฐํฌ ํ™˜๊ฒฝ์—์„œ ์™ธ๋ถ€ ๊ฒฝ๋กœ ์ด๋ฏธ์ง€๊ฐ€ ์•ˆ๋œฐ ๋•Œ ์•„๋ž˜ ์˜ต์…˜ ์ถ”๊ฐ€
(Next Image๋Š” ์ž๋™์œผ๋กœ ์ด๋ฏธ์ง€ ์ตœ์ ํ™”๋ฅผ ์ง€์›ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ๊ฐ€ ๋‹ฌ๋ผ์ง. ๋ฐฐํฌํ™˜๊ฒฝ์—์„œ๋Š” ์ด๋ฏธ์ง€๊ฐ€ ๊นจ์ง)

unoptimized={true} // ์ตœ์ ํ™”๊ฐ€ ์ด๋ฃจ์–ด์ง€์ง€ ์•Š๋Š”๋‹ค

์ด๋ฏธ์ง€๊ฐ€ ์™ธ๋ถ€ ๊ฒฝ๋กœ์ผ ๊ฒฝ์šฐ ๋„๋ฉ”์ธ์„ ์ถ”๊ฐ€

/* next.config.ts */
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.example.com', // ์™ธ๋ถ€ ์ด๋ฏธ์ง€ ๋„๋ฉ”์ธ ์ถ”๊ฐ€
        port: '',
      },
    ],
  },
}

๐Ÿ’  next/font

next/font๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ธŒ๋ผ์šฐ์ €์—์„œ Google๋กœ ํฐํŠธ ์š”์ฒญ์„ ํ•˜์ง€ ์•Š์•„๋„ ๋œ๋‹ค.

src/font/font.ts

/* src/font/font.ts */
// ์›ํ•˜๋Š” ๊ตฌ๊ธ€ ํฐํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ
import { Inter, DynaPuff } from "next/font/google";

export const inter = Inter({ subsets: ["latin"] });

export const carlito = DynaPuff({
  subsets: ["latin"], // ์˜์–ด๋ฅผ ์ง€์›
  weight: ["400", "500", "600", "700"], // ๋ฐฐ์—ด ํ˜•ํƒœ๋กœ ์—ฌ๋Ÿฌ ๋‘๊ป˜๋ฅผ ์ง€์ •
  variable: "--carlito", // css์—์„œ var(--carlito)๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
});

src/app/layout.tsx

/* src/app/layout.tsx */
import "./globals.css";
import { carlito } from "@/font/font";

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      {/* ํฐํŠธ์„ค์ •์ด๋ฆ„.className์„ ๋„ฃ์œผ๋ฉด ์ ์šฉ ์™„๋ฃŒ */}
      <body className={carlito.className}>{children}</body>
    </html>
  );
}

(( ์ถ”๊ฐ€ ))

์ง์ ‘ ๋‹ค์šด๋กœ๋“œํ•œ ttf ํŒŒ์ผ์„ ์ ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•

import localFont from 'next/font/local';

const pretendard = localFont({
  src: '../../public/font/PretendardVariable.ttf',
  display: 'swap',
  weight: '45 920',
  variable: '--font-pretendard',
});

...
<html lang="en">
  <body className={pretendard.variable}>{children}</body>
</html>
/* tailwind.config.css */
extend: {
  fontFamily: {
    pretendard: ['var(--font-pretendard)'],
  },
},

๐Ÿ’  CSS Modules

ํŒŒ์ผ๋ช…์€ ํŒŒ์ผ์ด๋ฆ„.module.css๋กœ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.

module.css๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ํด๋ž˜์Šค ๊ฐ„์˜ ์ถฉ๋Œ์„ ํ”ผํ•  ์ˆ˜ ์žˆ๋‹ค.

.nav-wrap {
  display: flex;
}
import styles from './css/nav.module.css';

<div className={styles['nav-wrap']} />

๐Ÿ’  styled-components

app/layout.tsx

/* app/layout.tsx */
'use client';
import Layout from '../components/Layout/Layout';
import StyledComponentsRegistry from '../lib/registry'; // โญ๏ธ์ถ”๊ฐ€

const notoSans = Noto_Sans({ weight: '400', subsets: ['latin'] });

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        <StyledComponentsRegistry> // โญ๏ธ์ถ”๊ฐ€
          <Layout>{children}</Layout>
        </StyledComponentsRegistry> // โญ๏ธ์ถ”๊ฐ€
      </body>
    </html>
  );
}

lib/registry ์ฐธ๊ณ 

lib/registry.tsx

'use client';

import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';

export default function StyledComponentsRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  // Only create stylesheet once with lazy initial state
  // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
  const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet());

  useServerInsertedHTML(() => {
    const styles = styledComponentsStyleSheet.getStyleElement();
    styledComponentsStyleSheet.instance.clearTag();
    return <>{styles}</>;
  });

  if (typeof window !== 'undefined') return <>{children}</>;

  return (
    <StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
      {children}
    </StyleSheetManager>
  );
}

next.config.js

/* next.config.js */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  compiler: {
    styledComponents: true,
  },
};
module.exports = nextConfig;
...

๐Ÿ’  svg ์ปดํฌ๋„ŒํŠธ

Next.js - SVGR

# ์„ค์น˜
npm install @svgr/webpack

next.config.js

/* next.config.js */
/** @type {import('next').NextConfig} */
webpack(config) {
  const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg'));
  config.module.rules.push(
    {
      ...fileLoaderRule,
      test: /\.svg$/i,
      resourceQuery: /url/,
    },
    {
      test: /\.svg$/i,
      issuer: fileLoaderRule.issuer,
      resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] },
      use: ['@svgr/webpack'],
    },
  );
  fileLoaderRule.exclude = /\.svg$/i;

  return config;
}

svgํŒŒ์ผ๋“ค์„ publicํด๋”์— ๋„ฃ์–ด์ฃผ๊ณ  ์‚ฌ์šฉ

import MoonBlue from '../../../public/moonBlue.svg';

<MoonBlue /> // โญ๏ธ ์ปดํฌ๋„ŒํŠธ์ฒ˜๋Ÿผ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

๐Ÿ’  Suspense

suspense

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
 
export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

๐Ÿ’  CORS ์˜ค๋ฅ˜

rewrites

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: false,
  images: {
    domains: ['...'],
  },
  async rewrites() {  // โญ๏ธ ์ถ”๊ฐ€
    return [
      {
        source: '/api/:path*',  // fetchํ•˜๋Š” ๊ณณ์—์„œ '/api'๋ฅผ ์•ž์— ๋ถ™์ด๋ฉด ๊ฒฝ๋กœ๋ฅผ destination์œผ๋กœ ์žก์•„์คŒ
        destination: 'http://localhost:3001/:path*',
      },
    ];
  },
};

module.exports = nextConfig;

๐Ÿ’  metadata, viewport

import type { Metadata, Viewport } from 'next';

export const metadata: Metadata = {
  manifest: '/manifest.json',
  title: 'ํƒ€์ดํ‹€',
  description: '์„ค๋ช…',
  icons: {
    icon: '/favicon.ico',
  },
  openGraph: {
    images: ['์ด๋ฏธ์ง€ ๊ฒฝ๋กœ']
  },
  alternates: {
    canonical: '๋ฐฐํฌํ•œ ์‚ฌ์ดํŠธ ์ฃผ์†Œ',
  },
  verification: {
    google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION,
    other: {
      'naver-site-verification': `${process.env.NEXT_PUBLIC_NAVER_SITE_VERIFICATION}`,
    }
  },
};

// ๋ชจ๋ฐ”์ผ์—์„œ ์†์œผ๋กœ ํ™”๋ฉด ํ™•๋Œ€ ์•ˆ๋˜๊ฒŒ
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
  userScalable: false,
};

๐Ÿ’  generateMetadata

interface Params {
  params: { projectId: string }
}

// ๋™์ ์œผ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜
export async function generateMetadata({ params }: Params): Promise<Metadata> {
  const { projectId } = params;
  
  return {
    title: `PROJECTS - ${projectId}`,
    description: `project - ${projectId}`,
  };
}

๐Ÿ’  ์ฒซ ์‹œ์ž‘ํŽ˜์ด์ง€ ๋ณ€๊ฒฝํ•˜๊ธฐ

next.config.mjs

const nextConfig = {
  async redirects() {
    return [
      {
        source: "/",
        destination: "/app", // ์ฒซ ํŽ˜์ด์ง€๊ฐ€ '/app' ๊ฒฝ๋กœ์—์„œ ์‹œ์ž‘ํ•จ
        permanent: true,
      },
    ];
  },
};

๐Ÿ’  middleware ๋ฏธ๋“ค์›จ์–ด

nextjs middleware

middleware.ts

/* src/middleware.ts */
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-pathname', request.nextUrl.pathname);
  requestHeaders.set('x-test', 'test');

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

layout.tsx

/* src/app/layout.tsx */
import { headers } from 'next/headers';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const headersList = headers();
  const headerPathname = headersList.get('x-pathname') || '';
  const test = headersList.get('x-test') || '';
  console.log('headersList:', headersList);
  console.log('headerPathname:', headerPathname);
  console.log('test:', test);
  ...

๐Ÿ’  redirect

server components์—์„œ ๊ฒฝ๋กœ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ

import { Metadata } from 'next';
import { redirect } from 'next/navigation';

export const metadata: Metadata = {
  title: 'TATA-V :: ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค',
  description: 'ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค',
};

function TestPage() {
  redirect('/test2');
}

export default TestPage;

๐Ÿ’  sitemap

sitemap


๐Ÿ’  robots

robots



๐Ÿ‘‰ (๊ณต์‹๋ฌธ์„œ)generateMetadata
๐Ÿ‘‰ (๊ณต์‹๋ฌธ์„œ)generateStaticParams
๐Ÿ‘‰ (๊ณต์‹๋ฌธ์„œ)RevalidatingData
๐Ÿ‘‰ (๊ณต์‹๋ฌธ์„œ)api ๋งŒ๋“ค๊ธฐ
๐Ÿงฟ SSR, CSR, SSG

profile
๐ŸŒฟ https://www.tatahyeonv.com

0๊ฐœ์˜ ๋Œ“๊ธ€