들어가며
사용자가 다른 페이지로 이동했을 때, 브라우저 탭의 title이 그대로 "Home"으로 남아있나요?
홈페이지: "My App"
제품 상세 페이지: "My App" (변경 안 됨)
사용자 프로필: "My App" (변경 안 됨)
❌ 나쁜 경험
제대로 된 웹사이트는 페이지마다 고유한 title을 가져야 합니다.
홈페이지: "Home - My App"
제품 상세 페이지: "iPhone 15 - My Shop"
사용자 프로필: "John Doe - My App"
✅ 좋은 경험
이 가이드에서는 React와 Next.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 개선
- 소셜 미디어 공유 최적화