본문 바로가기
프론트엔드

동적 페이지 Title 변경 - React & Next.js

by 코딩의 세계 2026. 3. 3.

들어가며

사용자가 다른 페이지로 이동했을 때, 브라우저 탭의 title이 그대로 "Home"으로 남아있나요?

홈페이지: "My App"
제품 상세 페이지: "My App" (변경 안 됨)
사용자 프로필: "My App" (변경 안 됨)

❌ 나쁜 경험

제대로 된 웹사이트는 페이지마다 고유한 title을 가져야 합니다.

홈페이지: "Home - My App"
제품 상세 페이지: "iPhone 15 - My Shop"
사용자 프로필: "John Doe - My App"

✅ 좋은 경험

이 가이드에서는 ReactNext.js에서 동적으로 title을 변경하는 방법을 배웁니다.


1. React에서 Title 변경

방법 1: useEffect와 document.title

기본 사용법

typescript
import { useEffect } from 'react'

function HomePage() {
  useEffect(() => {
    document.title = 'Home - My App'
  }, [])

  return <h1>Home</h1>
}

export default HomePage

페이지마다 다른 title 설정

typescript
function ProductPage({ productId }) {
  const [product, setProduct] = useState(null)

  useEffect(() => {
    // 제품 정보 로드
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data)
        // 제품명으로 title 변경
        document.title = `${data.name} - My Shop`
      })
  }, [productId])

  return <div>{product?.name}</div>
}

라우터 변경 시 title 업데이트

typescript
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'

function App() {
  const location = useLocation()

  useEffect(() => {
    // 경로에 따라 title 설정
    const titleMap = {
      '/': 'Home - My App',
      '/about': 'About - My App',
      '/contact': 'Contact - My App',
      '/products': 'Products - My Shop'
    }

    document.title = titleMap[location.pathname] || 'My App'
  }, [location.pathname])

  return <div>...</div>
}

방법 2: react-helmet으로 관리

설치

bash
npm install react-helmet

기본 사용법

typescript
import { Helmet } from 'react-helmet'

function HomePage() {
  return (
    <>
      <Helmet>
        <title>Home - My App</title>
        <meta name="description" content="Welcome to My App" />
      </Helmet>
      <h1>Home</h1>
    </>
  )
}

동적 title 설정

typescript
import { Helmet } from 'react-helmet'
import { useParams } from 'react-router-dom'

function ProductPage() {
  const { productId } = useParams()
  const [product, setProduct] = useState(null)

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => setProduct(data))
  }, [productId])

  return (
    <>
      <Helmet>
        <title>{product?.name} - My Shop</title>
        <meta name="description" content={product?.description} />
        <meta property="og:title" content={product?.name} />
        <meta property="og:image" content={product?.image} />
      </Helmet>
      <div>{product?.name}</div>
    </>
  )
}

전역 Helmet 설정

typescript
import { Helmet, HelmetProvider } from 'react-helmet-async'

function App() {
  return (
    <HelmetProvider>
      <Helmet>
        <title>My App</title>
        <meta name="theme-color" content="#000000" />
      </Helmet>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products/:id" element={<ProductPage />} />
      </Routes>
    </HelmetProvider>
  )
}

방법 3: useDocumentTitle 커스텀 훅

훅 작성

typescript
// hooks/useDocumentTitle.ts
import { useEffect } from 'react'

export function useDocumentTitle(title: string) {
  useEffect(() => {
    document.title = title
    return () => {
      // cleanup (옵션)
    }
  }, [title])
}

컴포넌트에서 사용

typescript
function HomePage() {
  useDocumentTitle('Home - My App')
  return <h1>Home</h1>
}

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null)

  useDocumentTitle(product?.name ? `${product.name} - My Shop` : 'Loading...')

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => setProduct(data))
  }, [productId])

  return <div>{product?.name}</div>
}

방법 4: 라우터 메타데이터 패턴

라우터 설정

typescript
// routes.tsx
const routes = [
  {
    path: '/',
    component: HomePage,
    meta: {
      title: 'Home - My App',
      description: 'Welcome to My App'
    }
  },
  {
    path: '/about',
    component: AboutPage,
    meta: {
      title: 'About - My App',
      description: 'About us'
    }
  },
  {
    path: '/products/:id',
    component: ProductPage,
    meta: {
      title: 'Product', // 동적으로 변경될 수 있음
      description: 'Product details'
    }
  }
]

라우터 변경 감지

typescript
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { routes } from './routes'

function App() {
  const location = useLocation()

  useEffect(() => {
    const route = routes.find(r => {
      const pattern = new RegExp(`^${r.path.replace(/:\w+/g, '[^/]+')}$`)
      return pattern.test(location.pathname)
    })

    if (route?.meta?.title) {
      document.title = route.meta.title
    }
  }, [location.pathname])

  return <div>...</div>
}

2. Next.js에서 Title 변경

방법 1: next/head 사용 (Pages Router)

기본 사용법

typescript
// pages/index.tsx
import Head from 'next/head'

export default function Home() {
  return (
    <>
      <Head>
        <title>Home - My App</title>
        <meta name="description" content="Welcome to My App" />
      </Head>
      <h1>Home</h1>
    </>
  )
}

동적 title 설정

typescript
// pages/products/[id].tsx
import Head from 'next/head'
import { GetServerSideProps } from 'next'

interface ProductPageProps {
  product: {
    id: string
    name: string
    description: string
    image: string
  }
}

export default function ProductPage({ product }: ProductPageProps) {
  return (
    <>
      <Head>
        <title>{product.name} - My Shop</title>
        <meta name="description" content={product.description} />
        <meta property="og:title" content={product.name} />
        <meta property="og:image" content={product.image} />
        <meta property="og:description" content={product.description} />
      </Head>
      <div>
        <h1>{product.name}</h1>
        <img src={product.image} alt={product.name} />
        <p>{product.description}</p>
      </div>
    </>
  )
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { id } = context.params as { id: string }

  const response = await fetch(`https://api.example.com/products/${id}`)
  const product = await response.json()

  return {
    props: {
      product
    }
  }
}

컴포넌트 재사용

typescript
// components/PageHead.tsx
import Head from 'next/head'

interface PageHeadProps {
  title: string
  description?: string
  ogImage?: string
}

export function PageHead({
  title,
  description = 'Welcome to My App',
  ogImage
}: PageHeadProps) {
  return (
    <Head>
      <title>{title}</title>
      <meta name="description" content={description} />
      {ogImage && <meta property="og:image" content={ogImage} />}
      <meta property="og:title" content={title} />
      <meta property="og:description" content={description} />
    </Head>
  )
}

// pages/index.tsx
import { PageHead } from '@/components/PageHead'

export default function Home() {
  return (
    <>
      <PageHead
        title="Home - My App"
        description="Welcome to My App"
      />
      <h1>Home</h1>
    </>
  )
}

방법 2: App Router에서 Metadata 사용 (Next.js 13+)

정적 메타데이터

typescript
// app/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Home - My App',
  description: 'Welcome to My App',
}

export default function Home() {
  return <h1>Home</h1>
}

동적 메타데이터 (generateMetadata)

typescript
// app/products/[id]/page.tsx
import { Metadata, ResolvingMetadata } from 'next'

interface ProductPageProps {
  params: {
    id: string
  }
}

// 페이지 렌더링 전에 메타데이터 생성
export async function generateMetadata(
  { params }: ProductPageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  // API에서 제품 정보 로드
  const product = await fetch(
    `https://api.example.com/products/${params.id}`
  ).then(res => res.json())

  return {
    title: `${product.name} - My Shop`,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [product.image]
    }
  }
}

export default function ProductPage({ params }: ProductPageProps) {
  return <div>Product: {params.id}</div>
}

레이아웃에서 메타데이터 확장

typescript
// app/layout.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s - My App' // 자동으로 " - My App" 추가
  },
  description: 'Welcome to My App',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

// app/about/page.tsx
export const metadata: Metadata = {
  title: 'About', // 자동으로 "About - My App"이 됨
}

export default function AboutPage() {
  return <h1>About</h1>
}

조건부 메타데이터

typescript
// app/user/[id]/page.tsx
import { Metadata, ResolvingMetadata } from 'next'

interface UserPageProps {
  params: { id: string }
}

export async function generateMetadata(
  { params }: UserPageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const user = await fetch(
    `https://api.example.com/users/${params.id}`
  ).then(res => res.json())

  // 부모 메타데이터 접근
  const parentMetadata = await parent

  return {
    title: `${user.name} - Profile`,
    description: user.bio,
    robots: user.isPrivate ? 'noindex' : 'index' // 프라이빗 프로필은 검색 제외
  }
}

export default function UserPage({ params }: UserPageProps) {
  return <div>User: {params.id}</div>
}

방법 3: 클라이언트에서 동적 변경 (App Router)

typescript
// app/search/page.tsx
'use client'

import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'

export default function SearchPage() {
  const searchParams = useSearchParams()
  const query = searchParams.get('q') || ''

  useEffect(() => {
    // 클라이언트에서 동적으로 title 변경
    document.title = query ? `"${query}" - Search - My App` : 'Search - My App'
  }, [query])

  return <h1>Search results for "{query}"</h1>
}

3. 메타데이터 베스트 프랙티스

Open Graph 메타데이터

typescript
// React
import { Helmet } from 'react-helmet'

function ProductPage({ product }) {
  return (
    <Helmet>
      <title>{product.name} - My Shop</title>
      <meta name="description" content={product.description} />

      {/* Open Graph (소셜 미디어 공유) */}
      <meta property="og:type" content="product" />
      <meta property="og:title" content={product.name} />
      <meta property="og:description" content={product.description} />
      <meta property="og:image" content={product.image} />
      <meta property="og:url" content={window.location.href} />

      {/* Twitter Card */}
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:title" content={product.name} />
      <meta name="twitter:description" content={product.description} />
      <meta name="twitter:image" content={product.image} />
    </Helmet>
  )
}
 
typescript
// Next.js (App Router)
export async function generateMetadata({
  params,
}: {
  params: { id: string }
}): Promise<Metadata> {
  const product = await fetchProduct(params.id)

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      type: 'product',
      title: product.name,
      description: product.description,
      images: [
        {
          url: product.image,
          width: 1200,
          height: 630,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  }
}

4. SEO 최적화

Canonical URL 설정

typescript
// React
import { Helmet } from 'react-helmet'

function Page() {
  return (
    <Helmet>
      <link rel="canonical" href="https://example.com/page" />
    </Helmet>
  )
}

// Next.js
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  alternates: {
    canonical: '/page'
  }
}

언어 설정

typescript
// React
function Page() {
  return (
    <Helmet>
      <html lang="ko" />
    </Helmet>
  )
}

// Next.js
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  )
}

Robots 메타 태그

typescript
// React
function PrivatePage() {
  return (
    <Helmet>
      <meta name="robots" content="noindex, nofollow" />
    </Helmet>
  )
}

// Next.js
export const metadata: Metadata = {
  robots: {
    index: false,
    follow: false
  }
}

5. 실전 예제

React + React Router 예제

typescript
// hooks/useTitleWithMetadata.ts
import { useEffect } from 'react'
import { Helmet } from 'react-helmet'

export function useTitleWithMetadata(
  title: string,
  description: string,
  ogImage?: string
) {
  return (
    <Helmet>
      <title>{title}</title>
      <meta name="description" content={description} />
      {ogImage && (
        <>
          <meta property="og:title" content={title} />
          <meta property="og:description" content={description} />
          <meta property="og:image" content={ogImage} />
        </>
      )}
    </Helmet>
  )
}

// pages/ProductPage.tsx
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useTitleWithMetadata } from '@/hooks/useTitleWithMetadata'

function ProductPage() {
  const { id } = useParams()
  const [product, setProduct] = useState(null)

  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
  }, [id])

  return (
    <>
      {useTitleWithMetadata(
        product?.name || 'Loading...',
        product?.description || '',
        product?.image
      )}
      <div>
        <h1>{product?.name}</h1>
        <p>{product?.description}</p>
      </div>
    </>
  )
}

export default ProductPage

Next.js App Router 예제

typescript
// lib/metadata.ts
import { Metadata, ResolvingMetadata } from 'next'

export async function generateProductMetadata(
  productId: string,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const product = await fetch(
    `${process.env.API_URL}/products/${productId}`
  ).then(res => res.json())

  return {
    title: `${product.name} - Shop`,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      type: 'product',
      images: [product.image],
    },
  }
}

// app/products/[id]/page.tsx
import { generateProductMetadata } from '@/lib/metadata'

export async function generateMetadata(
  { params }: { params: { id: string } },
  parent: ResolvingMetadata
): Promise<Metadata> {
  return generateProductMetadata(params.id, parent)
}

async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(
    `${process.env.API_URL}/products/${params.id}`
  ).then(res => res.json())

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </div>
  )
}

export default ProductPage

6. 비교표

React vs Next.js

React:
- document.title 직접 수정
- react-helmet 라이브러리 사용
- 클라이언트에서 변경
- 동적 메타데이터 설정 용이

Next.js (Pages Router):
- Head 컴포넌트 사용
- 반복적 코드 작성 필요
- 클라이언트 + SSR 지원
- getServerSideProps/getStaticProps 활용

Next.js (App Router):
- generateMetadata 함수 사용
- 정적 메타데이터 지원
- 자동 메타데이터 상속
- 가장 강력하고 간단함

7. 자주 묻는 질문

Q: 클라이언트에서 title 변경이 검색 엔진에 영향을 주나?

A: React는 영향을 줄 수 있습니다.

  • 검색 엔진이 JavaScript를 실행하지 않으면 원본 title만 색인
  • Next.js는 서버에서 처리하므로 완벽합니다
 
typescript
// React - 서버사이드 렌더링(SSR) 추가 필요
// Next.js - 기본적으로 SSR 지원

Q: 동적 데이터를 기반으로 title을 변경하려면?

A: 데이터를 먼저 로드한 후 title 설정

typescript
// React
useEffect(() => {
  fetchData().then(data => {
    document.title = data.title
  })
}, [])

// Next.js
export async function generateMetadata({ params }) {
  const data = await fetch(`/api/${params.id}`)
  return { title: data.title }
}

Q: 여러 페이지에서 같은 title 포맷을 사용하려면?

A: 유틸리티 함수나 템플릿 사용

typescript
// React
function usePageTitle(pageName: string) {
  useDocumentTitle(`${pageName} - My App`)
}

// Next.js
export const metadata: Metadata = {
  title: {
    template: '%s - My App',
    default: 'My App'
  }
}

8. 체크리스트

페이지 title 동적 변경 구현:

[ ] React: document.title 또는 react-helmet 선택
[ ] 모든 주요 페이지에 title 설정
[ ] Open Graph 메타데이터 추가
[ ] 동적 데이터 기반 title 설정
[ ] SEO 메타태그 추가
[ ] 모바일 환경 테스트
[ ] 브라우저 개발자 도구에서 확인
[ ] SEO 도구(Google Search Console 등)에서 검증

결론

React:

  • 간단한 프로젝트: document.title 직접 수정
  • 복잡한 프로젝트: react-helmet 사용

Next.js:

  • Pages Router: Head 컴포넌트
  • App Router (추천): generateMetadata + metadata

항상 메타데이터를 신경쓰세요!

  • 사용자 경험 향상
  • SEO 개선
  • 소셜 미디어 공유 최적화
 

'프론트엔드' 카테고리의 다른 글

Storybook - 컴포넌트 개발의 완전한 혁신  (1) 2026.02.24
Giscus란?  (0) 2026.02.06
MSW란?  (0) 2026.02.04
Deno란?  (0) 2026.02.03
FSD란?  (0) 2026.02.02