
next-seo란?
SEO(Search Engine Optimization)에 대해서는 이전 글에서 정리한 SEO 기본 개념을 참고하면 된다.
여기서는SEO중에서도 가장 기초가 되는 메타 태그 관리 문제를 어떻게 더 간단히 풀 수 있을지를 다룬다.
웹사이트를 만들면 꼭 따라오는 단어가 하나 있다. 바로 SEO(Search Engine Optimization, 검색 엔진 최적화)다.
쉽게 말해, 내 웹사이트가 구글이나 네이버 같은 검색엔진에서 잘 보이게 만드는 기술이다.
SEO에 대해 깊이 들어가면 복잡하지만, 가장 기본은 메타 태그(meta tag) 라는 걸 잘 관리하는 데서 시작한다.예를 들어 이런 태그들이다:
<title>: 브라우저 탭에 보이는 제목<meta name="description">: 검색결과에 표시되는 요약 설명<meta property="og:image">: 카톡이나 트위터에 링크를 붙였을 때 나오는 썸네일 이미지
Next.js에서는 이런 태그를next/head라는 컴포넌트로 직접 작성할 수 있다.
하지만 페이지가 많아지면 이런 코드가 여기저기 중복되고, 실수로 빼먹기도 쉽다.
👉 여기서 등장하는 게 바로next-seo라는 라이브러리다.- Next.js 프로젝트에서 SEO 태그를 한 곳에서 관리할 수 있게 도와준다.
- 사이트 공통 설정(DefaultSeo) 과 페이지별 개별 설정(NextSeo) 을 깔끔하게 분리할 수 있다.
- Open Graph(OG)나 Twitter Card 같은 소셜 공유용 태그도 자동으로 붙여준다.
즉,
next-seo는 “SEO 태그를 손쉽게 관리해주는 도우미”라고 생각하면 된다.
Next.js에서 규모 있는 프로젝트를 하려면 거의 필수에 가깝다.
next-seo를 사용해야 할까?Next.js에서는 기본적으로
next/head를 이용해SEO 태그를 직접 작성할 수 있다.
예를 들어 블로그 글 페이지라면 이렇게 쓸 수 있다:import Head from "next/head"; export default function BlogPage() { return ( <> <Head> <title>내 블로그 글 제목</title> <meta name="description" content="이 글은 Next.js SEO에 관한 글입니다." /> <meta property="og:title" content="내 블로그 글 제목" /> <meta property="og:description" content="이 글은 Next.js SEO에 관한 글입니다." /> <meta property="og:image" content="/og-image.png" /> </Head> <main>...</main> </> ); }겉보기에는 큰 문제가 없어 보인다. 하지만 프로젝트 규모가 커지면 이런 방식은 곧 한계에 부딪힌다.
❌
next/head만 쓸 때 문제점
- 페이지마다 중복되는 태그를 계속 써야 한다 (title, description, OG 등)
- 기본값이 바뀌면 모든 파일을 수정해야 한다
- 태그를 빠뜨리거나 오타를 내면 검색 노출이 떨어진다
- 소셜 미리보기(카톡/트위터 카드)까지 챙기려면 태그 관리가 점점 복잡해진다
이런 이유로 규모가 커지면 유지보수가 힘들어진다.
✅
next-seo를 쓰면 좋은 점
- 한 곳에서 전역 기본값 관리: 사이트 전체에 공통으로 적용되는 제목, 설명, 썸네일 이미지를 설정할 수 있다.
- 페이지마다 필요한 부분만 덮어쓰기: 블로그 글, 제품 상세 페이지처럼 개별 SEO 정보가 필요한 곳에서만 추가 설정 가능.
- OG/Twitter 카드 자동화: 소셜 미리보기 태그를 한 번에 관리할 수 있다.
- JSON-LD 구조화 데이터 지원: 검색엔진이 페이지 성격(FAQ, 블로그 글, 제품 정보 등)을 더 잘 이해하도록 도와준다.
DefaultSeo)무엇을 하는 기능인가?
- 사이트 전체에 공통적으로 적용될 SEO 정보(제목, 설명, 대표 이미지 등)를 한 번에 설정한다.
- 예를 들어 “사이트명”, “서비스 설명”, “대표 OG 이미지” 같은 값은 모든 페이지에 반복적으로 쓰이는데, 이를 전역 기본값으로 묶어두면 새 페이지를 만들 때 따로 적지 않아도 자동으로 반영된다.
DefaultSeo는 말 그대로 “SEO의 기본 세트”를 사이트 전역에서 관리하는 역할을 한다.
왜 중요한가?- 공통 정보를 한 곳에서 관리하므로 브랜드명·대표 이미지 변경 시 한 번 수정으로 전체 반영할 수 있다.
- 모든 페이지가 최소한의 SEO 정보를 자동으로 가져가기 때문에 빠뜨리는 실수를 막고, 일관성을 유지할 수 있다.
코드 예시// src/seo/next-seo.config.ts // 👉 사이트 전역 기본 SEO 세팅을 모아두는 설정 파일 import type { DefaultSeoProps } from "next-seo"; const SEO_CONFIG: DefaultSeoProps = { titleTemplate: "%s | PlanMate", defaultTitle: "PlanMate - 나만의 맞춤형 플래너", description: "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너", canonical: "https://planmate.com", openGraph: { type: "website", locale: "ko_KR", url: "https://planmate.com", siteName: "PlanMate", images: [ { url: "https://planmate.com/og/og-default.png", // OG 기본 이미지 width: 1200, height: 630, alt: "PlanMate 대표 이미지", }, ], }, twitter: { cardType: "summary_large_image", }, }; export default SEO_CONFIG;// app/layout.tsx // 👉 App Router의 최상위 레이아웃에서 불러와 전역에 적용 import "./globals.css"; import { DefaultSeo } from "next-seo"; import SEO_CONFIG from "@/seo/next-seo.config"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> {/* 전역 SEO 기본값을 사이트 전체에 적용 */} <DefaultSeo {...SEO_CONFIG} /> {children} </body> </html> ); }
NextSeo)무엇을 하는 기능인가?
- 블로그 글이나 제품 상세 페이지처럼 각 페이지마다 다른 SEO 정보가 필요한 경우, 전역 기본값을 덮어쓰는 기능이다.
- 예를 들어 블로그 글 상세 페이지라면, 글의 제목과 요약이
<title>과<meta description>에 들어가야 한다. 이때NextSeo를 사용하면 해당 페이지에서만 전역 기본값을 덮어써서 고유한 정보를 제공할 수 있다.NextSeo는 전역 설정을 토대로, 페이지별로 고유한 제목·설명·이미지·URL을 부여하는 역할을 한다.
왜 중요한가?- 검색엔진은 각 페이지가 고유한 메타 정보를 가질 때 더 높은 점수를 준다.
- 모든 페이지가 똑같은 제목·설명을 갖고 있으면 검색엔진 입장에서는 중복 콘텐츠로 인식돼 노출이 불리해진다.
코드 예시// app/blog/[slug]/page.tsx // 👉 블로그 상세 페이지에서 글마다 다른 SEO 태그 적용 import { NextSeo } from "next-seo"; export default function BlogDetailPage({ params }: { params: { slug: string } }) { const url = `https://planmate.com/blog/${params.slug}`; return ( <> <NextSeo title="Zustand vs Redux - 무엇을 선택할까?" description="프론트엔드 상태관리 라이브러리 비교, 선택 기준 정리" canonical={url} openGraph={{ type: "article", url, title: "Zustand vs Redux - 무엇을 선택할까?", description: "프론트엔드 상태관리 라이브러리 비교, 선택 기준 정리", images: [ { url: "https://planmate.com/og/zustand-vs-redux.png", width: 1200, height: 630, alt: "상태 관리 비교", }, ], article: { publishedTime: "2025-10-01T09:00:00+09:00", modifiedTime: "2025-10-02T10:00:00+09:00", authors: ["https://planmate.com/about"], }, }} /> <main>{/* 블로그 본문 내용 */}</main> </> ); }
무엇을 하는 기능인가?
- 링크를 카카오톡, 페이스북, 트위터, 슬랙 등에 공유했을 때 표시되는 미리보기 카드(제목, 설명, 이미지) 를 자동으로 만들어준다.
- Open Graph(OG)와 Twitter Card 표준을 지원해, 어디서 공유하든 일관된 미리보기를 보여줄 수 있다.
og:title,og:description,og:image같은 태그를 반복 작성하지 않아도 된다.
왜 중요한가?- 공유 카드가 깔끔하면 클릭률(CTR)이 올라가고 유입이 늘어난다.
- 브랜드 신뢰도를 높이고, SNS에서 콘텐츠가 눈에 잘 띄도록 해준다.
코드 예시// app/promo/fall-2025/page.tsx // 👉 프로모션 랜딩 페이지에서 공유용 OG/Twitter 카드 지정 import { NextSeo } from "next-seo"; export default function PromoFall2025Page() { return ( <> <NextSeo title="PlanMate 가을 프로모션" description="지금 가입하면 3개월 무료! 커스텀 플래너를 경험해보자." openGraph={{ title: "PlanMate 가을 프로모션", description: "지금 가입하면 3개월 무료! 커스텀 플래너를 경험해보자.", images: [ { url: "https://planmate.com/og/promo-fall-2025.png", width: 1200, height: 630, alt: "가을 프로모션 배너", }, ], }} twitter={{ cardType: "summary_large_image" }} /> <main>{/* 프로모션 콘텐츠 */}</main> </> ); }
무엇을 하는 기능인가?
- 검색엔진이 페이지 성격을 정확히 이해할 수 있도록 JSON-LD 구조화 데이터를 붙인다.
- 블로그 글은
Article, FAQ는FAQPage, 상품은Product처럼 각 페이지 목적에 맞는 스키마를 선언할 수 있다.next-seo는ArticleJsonLd,FAQPageJsonLd같은 컴포넌트를 제공한다.
왜 중요한가?- 조건을 만족하면 구글 검색결과에서 리치 스니펫(FAQ 토글, 별점, 가격 등) 으로 노출되어 클릭률 상승 효과를 기대할 수 있다.
코드 예시// app/blog/next-seo-guide/page.tsx // 👉 블로그 글 + FAQ 섹션에 구조화 데이터 추가 import { NextSeo, ArticleJsonLd, FAQPageJsonLd } from "next-seo"; export default function NextSeoGuideArticle() { const url = "https://planmate.com/blog/next-seo-guide"; return ( <> <NextSeo title="Next.js에서 next-seo 완전 가이드" description="메타/OG/JSON-LD 관리법" canonical={url} /> <ArticleJsonLd url={url} title="Next.js에서 next-seo 완전 가이드" images={["https://planmate.com/og/next-seo-guide.png"]} datePublished="2025-10-01" dateModified="2025-10-02" authorName="이현석" publisherName="PlanMate" description="메타 태그와 OG, JSON-LD를 next-seo로 관리하는 방법" /> <FAQPageJsonLd mainEntity={[ { questionName: "OG 이미지 권장 크기는?", acceptedAnswerText: "1200x630 픽셀을 권장합니다. 용량은 300KB 이하가 적당합니다.", }, ]} /> <main>{/* 글 본문 */}</main> </> ); }
noindex, nofollow)무엇을 하는 기능인가?
- 특정 페이지를 검색엔진이 색인하지 않거나, 내부 링크를 따라가지 않도록 설정한다.
noindex는 페이지 자체가 검색결과에 나타나지 않게 하고,nofollow는 페이지 안의 링크를 따라가지 못하게 한다.- 마이페이지, 결제 완료 페이지, 관리자 화면 등 외부 노출이 불필요한 페이지에서 활용된다.
왜 중요한가?- 검색에 노출되면 안 되는 페이지가 실수로 올라가면 브랜딩·보안에 악영향을 끼친다.
- 필요한 페이지만 검색에 반영되도록 관리해 SEO 효율을 높일 수 있다.
코드 예시// app/internal/labs/page.tsx // 👉 내부 테스트용 페이지를 검색 색인에서 제외 import { NextSeo } from "next-seo"; export default function InternalLabs() { return ( <> <NextSeo title="Labs (Internal)" noindex nofollow robotsProps={{ nosnippet: true, noimageindex: true, }} /> <main>{/* 내부용 실험 페이지 */}</main> </> ); }
canonical, alternate)무엇을 하는 기능인가?
- 같은 내용이 여러 주소에서 보일 때, 대표 URL(canonical) 을 지정해 중복 문제를 해결한다.
- 다국어 페이지에서는
alternate속성을 통해 언어별 버전을 서로 연결해줄 수 있다. (ko,en,x-default)- 예:
/ko/post/1,/en/post/1같은 페이지를 올바르게 인식시키는 데 사용한다.
왜 중요한가?- 중복 페이지가 많으면 검색엔진이 점수를 분산시켜 검색 순위가 떨어질 수 있다.
- 다국어 사이트에서 사용자가 자신의 언어 버전을 더 쉽게 찾을 수 있게 도와준다.
코드 예시// app/[locale]/blog/[slug]/page.tsx // 👉 다국어 블로그 글 페이지에서 canonical + hreflang 지정 import { NextSeo } from "next-seo"; export default function I18nBlogPage({ params }: { params: { locale: "ko" | "en"; slug: string } }) { const base = "https://planmate.com"; const url = `${base}/${params.locale}/blog/${params.slug}`; return ( <> <NextSeo canonical={url} additionalLinkTags={[ { rel: "alternate", href: `${base}/ko/blog/${params.slug}`, hrefLang: "ko" }, { rel: "alternate", href: `${base}/en/blog/${params.slug}`, hrefLang: "en" }, { rel: "alternate", href: `${base}/blog/${params.slug}`, hrefLang: "x-default" }, ]} /> <main>{/* 다국어 페이지 본문 */}</main> </> ); }
next-seo 설치# npm npm install next-seo # yarn yarn add next-seo # pnpm pnpm add next-seo
- 타입 정의는 패키지에 포함되어 있어
@types/*별도 설치 불필요하다.App Router사용 시JSON-LD컴포넌트에useAppDir={true}옵션을 주는 걸 권장한다.
목표: 화면(UI) 없이도 전역 SEO 기본값을 깔고, 더미 라우트 1–2개로 OG/Twitter 카드·인덱싱이 올바르게 동작하는지 바로 확인.
(A) 베이스 URL 유틸 + 환경변수
# .env.local (로컬) NEXT_PUBLIC_SITE_URL=http://localhost:3000 # (예: Vercel Production) 환경변수 NEXT_PUBLIC_SITE_URL=https://planmate.com// src/seo/baseUrl.ts // 👉 절대 URL 생성 유틸. canonical/OG에 사용 export const getBaseUrl = () => { const fromEnv = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, ""); return fromEnv ?? "http://localhost:3000"; }; export const withBase = (path = "/") => { const base = getBaseUrl(); if (!path.startsWith("/")) return path; // 이미 절대 주소면 그대로 return `${base}${path}`; };💡 왜 “환경변수 + 유틸”로 나누나?
canonical,og:image등은 절대 URL을 요구/권장 → 소셜 크롤러에서 깨짐 방지.- 로컬/프리뷰/운영 도메인이 달라서 하드코딩하면 실수(랭킹 신호 분산, 카드 깨짐).
- SSR/SSG에선
window.location사용 불가 → 서버에서도 안전하게 절대경로 생성 필요.- 도메인 변경/서브도메인 분리 시 한 곳(env+유틸)만 바꾸면 전체 반영.
- 슬래시 중복/누락 같은 자잘한 버그를 유틸에서 흡수 → 메타 태그 품질 안정.
(B) 전역 기본값:
DefaultSeo주입// src/seo/next-seo.config.ts // 👉 사이트 공통 SEO 기본값 import type { DefaultSeoProps } from "next-seo"; import { getBaseUrl, withBase } from "./baseUrl"; const base = getBaseUrl(); const SEO_CONFIG: DefaultSeoProps = { titleTemplate: "%s | MyPlanMate", defaultTitle: "MyPlanMate - 나만의 맞춤형 플래너", description: "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너", canonical: base, openGraph: { type: "website", locale: "ko_KR", url: base, siteName: "MyPlanMate", images: [ { url: withBase("/og/og-default.png"), // 권장: 절대 URL width: 1200, height: 630, alt: "MyPlanMate 대표 이미지", }, ], }, twitter: { cardType: "summary_large_image" }, }; export default SEO_CONFIG;// app/layout.tsx // 👉 최상위 레이아웃에서 전역 기본값 1회 주입 // app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "./providers"; import { DefaultSeo } from "next-seo"; import SEO_CONFIG from "@/seo/next-seo.config"; // ✅ 중복 방지를 위해 title/description 제거하고 최소한만 유지 export const metadata: Metadata = { // 절대 URL이 필요한 일부 Next.js 내부 태그용(선택) metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"), // 여기엔 icons/manifest/themeColor 등만 두고, // title: "Custom Daily Planner", // ❌ 제거 (next-seo가 관리) // description: "나만의 맞춤형 플래너...", // ❌ 제거 (next-seo가 관리) }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> {/* ✅ 전역 SEO 기본값을 사이트 전체에 적용 */} <DefaultSeo {...SEO_CONFIG} /> {/* Providers로 감싸는 이유: - React Query의 QueryClientProvider를 전역 적용 - 모든 하위 컴포넌트가 동일한 client & cache 공유 - useQuery, useMutation 훅이 어디서든 정상 동작 */} {/* ✅ 추후 확장 예시: <AuthProvider> <ThemeProvider> <Providers>{children}</Providers> </ThemeProvider> </AuthProvider> */} <Providers>{children}</Providers> </body> </html> ); }.
(C) 더미 라우트로 바로 검증
OG/Twitter 카드 테스트
// app/lab/og-preview/page.tsx // 👉 공유 카드(OG/Twitter) 미리보기용 더미 페이지 import { NextSeo } from "next-seo"; import { withBase } from "@/seo/baseUrl"; export default function OgPreviewPage() { const title = "PlanMate 프로모션 Seed"; const desc = "공유 카드(OG/Twitter) 테스트용 페이지"; const image = withBase("/og/promo-seed.png"); return ( <> <NextSeo title={title} description={desc} openGraph={{ title, description: desc, images: [{ url: image, width: 1200, height: 630, alt: title }], }} twitter={{ cardType: "summary_large_image" }} /> <main className="p-8">OG/Twitter 카드 테스트 페이지</main> </> ); }색인 차단(noindex/nofollow) 테스트
// app/lab/noindex/page.tsx // 👉 내부/실험 페이지는 검색에서 가린다 import { NextSeo } from "next-seo"; export default function NoIndexPage() { return ( <> <NextSeo title="Labs (Internal)" noindex nofollow robotsProps={{ nosnippet: true, noimageindex: true }} /> <main className="p-8">내부 테스트 페이지(검색 제외)</main> </> ); }.
(D) 이번 단계 요약
- 베이스 URL 유틸 + 환경변수로 왜 분리를 했나?
DefaultSeo자체에 “필수”는 아니지만,canonical/og:image를 항상절대 URL로 안전하게 넣고(로컬·프리뷰·운영 환경 대응) 실수 줄이려는 권장 패턴이라 분리하였다.
- 이번에 실제로 주입한 기능은?
전역DefaultSeo한 가지. (app/layout.tsx에서<DefaultSeo {...SEO_CONFIG} />)
→사이트 전체 기본 타이틀/설명/OG/Twitter 카드 규칙을 “전역”으로 적용함.
- 테스트로 무엇을 했나?
더미 라우트 2종은 검증용일 뿐, 기능 추가가 아니다.
app/lab/og-preview/page.tsx→ OG/Twitter 카드가 제대로 표시되는지 확인app/lab/noindex/page.tsx→ noindex/nofollow가 정상 동작하는지 확인
- 나중에 페이지가 생성되면 무엇을 추가하나?
3-1 페이지별 SEO 오버라이드 (NextSeo)
페이지 고유값을 전역값 위에 덮어씀.
핵심 필드:title,description,canonical(절대 URL),openGraph.images[0].url(절대 URL)
상황별 보완:openGraph.url,openGraph.type(website/article/product),article.publishedTime/modifiedTime/authors등
3-2 소셜 미리보기 자동화 (Open Graph / Twitter Card)
공유 썸네일·제목·설명 품질 관리.
권장:twitter.cardType = "summary_large_image", 필요 시twitter.site/creator설정.
3-3 JSON-LD 구조화 데이터
페이지 목적에 맞는 스키마를 선택적으로 추가.
예:ArticleJsonLd(글),FAQPageJsonLd(FAQ),ProductJsonLd(상품),OrganizationJsonLd(브랜드),BreadcrumbJsonLd(빵부스러기).
3-4 검색 노출 제어 (noindex,nofollow)
내부/저가치/민감 페이지에만 제한적으로 적용. 필요 시robotsProps(예:nosnippet,noimageindex).
3-5 정규 URL & 다국어 (canonical,alternate)
중복 경로는 대표 주소로 canonical 고정.
다국어는alternate(hreflang)로 언어 버전 연결 + 각 언어 페이지는 self-canonical.
(E) 화면에서 무엇을 확인하나 (디버그 코드 없이)
1) 전역
DefaultSeo적용 확인 (아무 페이지나)
- 브라우저 탭 제목
- 전역
titleTemplate이 적용되어{페이지제목} | MyPlanMate형태로 보인다.NextSeo를 쓰지 않은 페이지(예: 홈)가 있다면, 탭 제목이 전역defaultTitle그대로여야 한다.- (개발자도구 Elements >
<head>확인)
meta[name="description"]가 전역description값과 일치link[rel="canonical"]이 절대 URL(환경변수 도메인)로 지정meta[property="og:image"]가 절대 URL인지
주의: 전역값은 기본값일 뿐. 이후 페이지에서NextSeo로 덮어쓰면 페이지값이 우선한다.
2)
app/lab/og-preview(OG/Twitter 카드 검증)
- 브라우저 탭 제목
PlanMate 프로모션 Seed | MyPlanMate처럼 페이지 제목 + 전역 템플릿이 보인다.
- (개발자도구
<head>확인)
meta[property="og:title"]/meta[property="og:description"]가 페이지에서 설정한 값으로 들어갔는지meta[property="og:image"]가 절대 URL이며 1개 이상 존재하는지meta[name="twitter:card"]가summary_large_image인지
- (선택) 외부 화면에서 미리보기 확인
- 카카오톡/슬랙/트위터에 페이지 URL을 붙여 카드 썸네일과 텍스트가 기대대로 보이는지
3)
app/lab/noindex(noindex/nofollow 검증)
- 브라우저 탭 제목
Labs (Internal) | MyPlanMate처럼 보이는지
- (개발자도구
<head>확인)
meta[name="robots"]의content에noindex, nofollow가 포함되어 있는지- 추가로 설정했다면
nosnippet,noimageindex도 함께 들어가 있는지
4) 절대 URL 일관성 (공통)
- (개발자도구
<head>확인)
link[rel="canonical"]의href가 환경변수에 설정한 도메인으로 시작하는지meta[property="og:image"]가 상대 경로(/og/...)가 아닌 절대 URL인지
위 4가지만 눈으로 확인하면, 4-1 단계의 목표(전역 기본값 적용 + OG/인덱싱 동작 확인)가 충족된다.
<DefaultSeo> 쓰다 터질 때상황 요약
<DefaultSeo>만 추가해 둔 상태였고, App Router(app/)에서 렌더링 에러가 발생했다.
원인은 간단히 말해<DefaultSeo>가 내부에서next/head를 쓰기 때문이고, App Router에서는next/head가 비호환이다.
1) 증상(대표 에러)
Invalid hook call/useContext(...) is null- 에러 스택에
Head/next/head표시- 페이지 진입 시 500
2) 원인
- App Router의
<head>는 Metadata API가 전담한다. Pages Router 시절처럼next/head로 직접 조작하지 않는다.
next-seo의<DefaultSeo>는 내부적으로next/head를 사용한다.
App Router(서버 컴포넌트·스트리밍 렌더링)에는next/head가 기대하는 Head 컨텍스트가 존재하지 않아 훅 호출이 깨지고, 그 결과Invalid hook call/useContext관련 오류가 난다.
- 결론적으로 “App Router +
<DefaultSeo>(=next/head) = 호환 불가” 이며,<NextSeo>도 동일한 이유로 오류가 난다.
3) 해결 방법
1.
<DefaultSeo>전부 제거
app/layout.tsx에서import { DefaultSeo } from "next-seo"및 JSX<DefaultSeo …/>삭제.- 전역 검색으로
DefaultSeo/NextSeo/next/head잔여물 없는지 확인.
2. 전역 메타를 Metadata API로 이관
app/layout.tsx에export const metadata추가(아래 예시 제공).metadataBase를NEXT_PUBLIC_SITE_URL로 설정해 canonical/OG의 절대 URL을 자동 보장.- 전역 값(타이틀 템플릿, 설명, OG/Twitter 기본값) 선언.
3. 공통 상수 분리
src/seo/constants.ts생성:SITE_URL,SITE_NAME,DEFAULT_TITLE,TITLE_TEMPLATE,DEFAULT_DESCRIPTION,OG_DEFAULT_IMAGE,LOCALE등 한 곳에서 관리.layout.tsx는 이 상수만 import해서 사용(변경 지점 단일화).
4. 테스트 라우트는 Metadata API만 사용
/app/lab/og-preview/page.tsx:export const metadata로title/description/og:image./app/lab/noindex/page.tsx:export const metadata.robots로index:false, follow:false.NextSeo/DefaultSeo는 테스트 라우트에서도 금지.
5. 환경 변수 점검
- 각 환경에
NEXT_PUBLIC_SITE_URL정확히 설정(마지막 슬래시 제거).- 도메인 변경 시
constants.ts/환경변수만 바꾸면 전역 반영.
6. OG 이미지/경로 확인
OG_DEFAULT_IMAGE및 각 페이지og:image가 실존 파일이며 공개 접근 가능(403/404 방지).- 권장: 1200×630, 용량 ≈300KB 이하, 절대 URL(또는
metadataBase로 절대화).
7. 중복/충돌 요소 정리
next-seo.config.ts는 삭제(또는 보관) — 더 이상 사용하지 않음.- 커스텀
<head>같은 중복 렌더링 로직 제거.
결론(해결방법 요약):
JSON-LD만next-seo로 넣고, 나머지 메타는 Next.js Metadata API로 처리하는 패턴이 현재 베스트 프랙티스다.
4) 적용 코드와 “왜 이렇게 고쳤는지”
4-1) 공통 상수
// src/seo/constants.ts export const SITE_NAME = "MyPlanMate"; export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ?? "http://localhost:3000"; export const DEFAULT_TITLE = `${SITE_NAME} - 나만의 맞춤형 플래너`; export const TITLE_TEMPLATE = `%s | ${SITE_NAME}`; export const DEFAULT_DESCRIPTION = "일간·주간·월간·투두·습관·메모를 자유롭게 조합하는 커스텀 플래너"; export const OG_DEFAULT_IMAGE = "/og/og-default.png"; // metadataBase로 절대 URL로 변환 export const LOCALE = "ko_KR";무엇을 고쳤나
- 삭제:
src/seo/next-seo.config.ts(더 이상 사용하지 않음)- 추가:
src/seo/constants.ts(도메인/브랜드/OG 기본 이미지 등 전역 상수의 단일 출처)
왜? 환경별 도메인/브랜드 텍스트/이미지 경로를 한 군데에서 관리하면 전역/페이지 일관성이 생기고, 변경도 한 곳만 고치면 끝.
4-2) 전역 메타:
app/layout.tsx// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "./providers"; import { SITE_NAME, SITE_URL, DEFAULT_TITLE, TITLE_TEMPLATE, DEFAULT_DESCRIPTION, OG_DEFAULT_IMAGE, LOCALE, } from "@/seo/constants"; export const metadata: Metadata = { metadataBase: new URL(SITE_URL), // ✅ 상대 경로를 절대 URL로 변환 title: { default: DEFAULT_TITLE, template: TITLE_TEMPLATE }, // ✅ 전역 타이틀 규칙 description: DEFAULT_DESCRIPTION, openGraph: { type: "website", url: "/", // ✅ metadataBase 기준으로 절대 URL 처리 siteName: SITE_NAME, title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION, images: [{ url: OG_DEFAULT_IMAGE, width: 1200, height: 630, alt: `${SITE_NAME} 대표 이미지` }], locale: LOCALE, }, twitter: { card: "summary_large_image" }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ko"> <body> <Providers>{children}</Providers> </body> </html> ); }무엇을 고쳤나
- 제거:
import { DefaultSeo } from "next-seo"/<DefaultSeo {...} />- 제거:
import SEO_CONFIG from "@/seo/next-seo.config"(파일 자체도 삭제)- 추가:
export const metadata로 전역title.template/description/openGraph/- 적용:
metadataBase = SITE_URL로canonical/og:image등 절대 URL 자동 보장
왜?metadataBase로"/og/og-default.png"/"/"같은 상대 경로를 자동 절대화.title.template로 전역 타이틀 패턴을 만들고, 나중에 페이지가 생기면 그 제목만 채우면 됨.- 전역
openGraph/twitter기본값을 깔아두면 누락/중복을 방지.
4-3) 테스트 라우트(메타만 선언)
// app/lab/og-preview/page.tsx import type { Metadata } from "next"; export const metadata: Metadata = { title: "PlanMate 프로모션 Seed", description: "공유 카드(OG/Twitter) 테스트용 페이지", openGraph: { title: "PlanMate 프로모션 Seed", description: "공유 카드(OG/Twitter) 테스트용 페이지", images: [{ url: "/og/promo-seed.png", width: 1200, height: 630, alt: "PlanMate 프로모션 Seed" }], }, twitter: { card: "summary_large_image" }, }; export default function Page() { return <main className="p-8">OG/Twitter 카드 테스트 페이지</main>; }// app/lab/noindex/page.tsx import type { Metadata } from "next"; export const metadata: Metadata = { title: "Labs (Internal)", robots: { index: false, follow: false, googleBot: { index: false, follow: false, noimageindex: true, nosnippet: true }, }, }; export default function Page() { return <main className="p-8">내부 테스트 페이지(검색 제외)</main>; }무엇을 고쳤나
- 제거: 두 파일에서
import { NextSeo } from "next-seo"및<NextSeo …/>- 추가: 각 파일 최상단에
export const metadata로 필요한 메타만 선언- 유지: 페이지 본문 UI는 그대로 (검증용 문구만 노출)
왜?<NextSeo>없이도 페이지별 메타를 안전하게 주입할 수 있다.robots는 의도치 않은 노출을 방지하는 안전장치(실험용 라우트에 추천).
화면 테스트
app/layout.tsx(전역 메타)무슨 파일?
- 사이트 전체에 적용되는 전역 메타데이터(타이틀 템플릿, 설명, OG/Twitter 기본값 등)를 선언하는 최상위 레이아웃.
브라우저 화면에서 확인할 것
- 탭 제목 패턴 동작
- 페이지에 별도 제목이 없으면:
MyPlanMate - 나만의 맞춤형 플래너가 탭에 보이는지.- 페이지에 제목이 있으면:
{페이지제목} | MyPlanMate형태로 보이는지. (예: 위og-preview,noindex)- 페이지 렌더 정상 여부
- 전역 메타로 바꾼 뒤에도 각 페이지 본문이 정상 노출되는지(라우팅 오류 없이).
전역
description, OG/Twitter 값 등은 화면에 직접 보이지 않음. 이 단계에서는 탭 제목 패턴이 올바르게 동작하는지만 보면 충분하다.
app/lab/og-preview/page.tsx무슨 파일?
- 페이지별 메타(특히 OG/Twitter 카드)가 정상 주입되는지 간단히 확인하는 공유 미리보기 전용 더미 페이지.
브라우저 화면에서 확인할 것
- 브라우저 탭 제목이
PlanMate 프로모션 Seed | MyPlanMate형태로 보이는지.- 본문에 “OG/Twitter 카드 테스트 페이지” 문구가 렌더되는지(라우팅/렌더 정상 확인).
메타 태그 자체는 화면에 안 보이므로, 화면에서 볼 수 있는 건 “탭 제목 패턴 + 본문 문구 노출”이 핵심이다.
app/lab/noindex/page.tsx무슨 파일?
- 검색 색인 제외 설정(noindex/nofollow)이 들어간 내부/실험용 더미 페이지.
브라우저 화면에서 확인할 것
- 브라우저 탭 제목이
Labs (Internal) | MyPlanMate형태로 보이는지.- 본문에 “내부 테스트 페이지(검색 제외)” 문구가 렌더되는지(의도한 페이지에 진입했는지 확인).
참고:
noindex효과는 검색엔진 측 처리라 화면만으로는 차이를 체감하기 어렵다. 화면에서는 의도된 페이지에 왔는지만 확인하면 충분하다.
왜: App Router → Metadata API로 간다
- App Router(현재 프로젝트):
DefaultSeo/NextSeo쓰지 않는다. 이 둘은 내부에서next/head를 쓰는데, App Router에선 비호환이다. → 메타는 전부 Metadata API로.- next-seo는 “JSON-LD 컴포넌트”만 사용한다. (
ArticleJsonLd,FAQPageJsonLd등,useAppDir={true}꼭)
언제 next-seo의
DefaultSeo/NextSeo를 쓰나?
- Pages Router(
pages/디렉토리): ✅ 사용 가능. 예전 방식이라next/head기반 컴포넌트가 정상 동작.- App Router(
app/디렉토리): ❌ 사용 불가/비추천. 대신 Metadata API로title/description/canonical/OG/Twitter/robots를 선언.
App Router에서의 권장 패턴
- 메타(타이틀/디스크립션/OG/Twitter/캐노니컬/로봇스) → Next.js Metadata API
- 구조화 데이터(JSON-LD) → next-seo의
*JsonLd만 (예:ArticleJsonLd),useAppDir={true}필수
App Router(app/)에서는<head>제어를 Metadata API가 맡았다.
next/head를 내부에서 쓰는<DefaultSeo>/<NextSeo>는 호환되지 않아 메타는 Metadata로 처리하기로 했다.
next-seo는 JSON-LD 전용으로만 쓰는 패턴을 채택했다.
지금까지 Metadata에 설정한 것(전역:
app/layout.tsx)
- title 템플릿:
"%s | MyPlanMate"- 기본 title/description: 사이트 공통 제목/설명
- metadataBase:
NEXT_PUBLIC_SITE_URL(상대 경로를 절대 URL로 자동 변환)- Open Graph 기본값:
type: "website",url: "/",siteName,title,description,images[0](대표 OG 이미지),locale- Twitter 카드 기본값:
card: "summary_large_image"- (구조) 전역 상수는
src/seo/constants.ts에서 관리결과: 아무 페이지에서도 탭 제목 패턴과 기본 메타가 안정적으로 적용된다.
페이지가 생기면 Metadata에 무엇을 써야 하나(오버라이드 체크리스트)
각
app/**/page.tsx마다 해당 페이지의 값만 덮어쓰면 된다.필수(최소)
title: 페이지 고유 제목description: 검색/공유 요약alternates.canonical: 대표 URL(상대 경로 OK,metadataBase가 절대화)
권장(공유 품질)
openGraph.title,openGraph.descriptionopenGraph.images[0].url(공유 썸네일, 1200×630 권장)twitter.card = "summary_large_image"
상황별 확장
- 글/뉴스 등:
openGraph.type = "article",publishedTime,modifiedTime,authors- 인덱싱 제어:
robots.index = false,robots.follow = false(내부/저가치 페이지)- 다국어:
alternates.languages(hreflang), 각 언어 페이지는 self-canonical- 동적 라우트:
generateMetadata()로 데이터 기반 값 주입
예시(간단)
// app/about/page.tsx import type { Metadata } from "next"; export const metadata: Metadata = { title: "소개", description: "MyPlanMate 소개 페이지", alternates: { canonical: "/about" }, openGraph: { title: "소개", description: "MyPlanMate 소개 페이지", images: [{ url: "/og/about.png", width: 1200, height: 630, alt: "소개" }], }, twitter: { card: "summary_large_image" }, };.
JSON-LD는 언제
next-seo로 쓰나(리치 스니펫용)메타 태그는 Metadata, 스키마(구조화 데이터)는 next-seo로 분담한다.
사용 시점(페이지 목적이 명확할 때만 추가)
- 글/블로그:
ArticleJsonLd- FAQ 섹션:
FAQPageJsonLd- 상품/가격:
ProductJsonLd- 브랜드/조직 소개:
OrganizationJsonLd- 빵부스러기:
BreadcrumbJsonLd
규칙
App Router에서는useAppDir={true}옵션을 반드시 넣기JSON-LD내용은 화면에 실제로 보이는 정보와 일치(미스매치 금지)- URL/이미지 경로는 절대 URL(또는
metadataBase로 보장)- 남발 금지: 해당 페이지에 “정말 필요한” 스키마만
예시
// app/blog/awesome-post/page.tsx import { ArticleJsonLd } from "next-seo"; export default function Page() { return ( <> {/* 본문... */} <ArticleJsonLd useAppDir={true} url="https://myplanmate.com/blog/awesome-post" title="완벽 가이드" images={["https://myplanmate.com/og/awesome-post.png"]} datePublished="2025-10-01" dateModified="2025-10-02" authorName="팀 MyPlanMate" publisherName="MyPlanMate" description="메타/OG/JSON-LD를 올바르게 쓰는 방법" /> </> ); }