Next.js로 SEO 제대로 하기 — 빈 프로젝트에서 시작해 직접 확인까지

Next.js 16 (App Router) · React 19 · TypeScript
새 프로젝트를 하나 만들어 SEO에 필요한 것들을 하나씩 붙여 보고, 정말로 동작하는지 끝까지 확인한 기록입니다.
글에 나오는 코드는 전부 실제로 띄워서 검증한 것이고, 명령 출력도 그대로 가져왔습니다.
검색에 잘 걸리게 만드는 일을 흔히 SEO라고 부르는데, "기능은 다 됐는데 구글에 검색하면 우리 페이지가 안 나온다"는 이야기를 종종 듣습니다. Next.js를 쓰고 있다면 SEO의 큰 부분은 이미 깔려 있고, 나머지는 메타데이터 몇 개를 제대로 채워 넣는 일에 가깝습니다. 그 "나머지"가 정확히 무엇인지, 빈 프로젝트에서 출발해 하나씩 붙여 보고 실제로 HTML에 담기는지까지 직접 확인해 봤습니다.
검색엔진은 무엇을 보는가
먼저 짚고 갈 게 하나 있습니다. 검색엔진은 사람이 보는 화면을 보지 않습니다. 구글 봇이 페이지에 들어오면 제일 먼저 하는 일은 서버가 내려준 HTML 원문을 그대로 읽는 것입니다. 그 HTML 안에 제목(<title>)과 설명(<meta name="description">)이 있고 본문 텍스트가 들어 있어야, "이 페이지는 이런 내용이구나" 하고 색인에 넣습니다. SEO의 출발점은 디자인이 아니라 서버가 내려주는 HTML에 의미 있는 정보가 들어 있는가입니다.
여기서 가장 흔히 걸리는 함정이 클라이언트 렌더링입니다.
"use client"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { fetch("/api/products/1").then((r) => r.json()).then(setProduct); }, []); if (!product) return <p>로딩 중...</p>; return <h1>{product.name}</h1>; }
화면에선 잘 동작합니다. 그런데 크롤러가 받아 가는 첫 HTML에는 <h1> 안에 상품 이름이 없습니다. 서버가 내려준 시점엔 로딩 중...만 있고, 상품 이름은 자바스크립트가 실행된 뒤에야 채워지기 때문입니다. 구글이 자바스크립트를 실행해 주긴 하지만 그 처리를 뒤로 미루고, 그동안 빈 페이지로 평가하거나 색인을 건너뛰기도 합니다.
그래서 검색에 노출되어야 하는 공개 페이지(메인·목록·상세)는 서버 컴포넌트에서 데이터를 받아 HTML에 담아 내려보내야 합니다. App Router의 기본이 서버 컴포넌트라, 사실 처음부터 이 방향으로 만들면 자연스럽게 해결됩니다. 위 코드는 이렇게 바뀝니다.
// 'use client' 없음 — 서버 컴포넌트(기본값) export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const product = await getProduct(id); // 서버에서 직접 데이터 fetch return <h1>{product.name}</h1>; // HTML에 이름이 박혀서 내려감 }
이게 왜 중요한지는 글 마지막에 curl로 직접 확인합니다. 받은 HTML 원문에 상품 이름이 보이면 통과. 로딩 중만 보이면 SEO상 문제입니다.
프로젝트 만들기
빈 프로젝트에서 시작합니다.
npx create-next-app@latest nextjs-seo-demo \ --typescript --app --no-tailwind --no-eslint --no-src-dir --import-alias "@/*"
설치가 끝나면 이런 버전이 잡힙니다(이 글을 쓰는 시점 기준).
next=16.2.9 react=19.2.4 typescript=^5
create-next-app이 만들어 준 app/layout.tsx를 열어 보면, 출발점은 이렇습니다.
export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; // ... <html lang="en" ...>
제목이 "Create Next App"이고 lang이 en입니다. 한국어 서비스인데 이걸 그대로 두면 검색 결과에 "Create Next App"이 그대로 뜨고, 검색엔진은 페이지 언어를 영어로 오해합니다. 첫 번째로 손볼 곳이 바로 여기입니다.
1. 루트 레이아웃 — <head>를 직접 건드리지 않는다
순수 React에서는 <head>에 <title>을 직접 넣거나 react-helmet 같은 라이브러리를 썼습니다. App Router에서는 그럴 필요가 없습니다. 파일에서 metadata 객체만 export하면 Next가 <head>를 만들어 줍니다. 기본 레이아웃을 이렇게 고쳤습니다.
// app/layout.tsx import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] }); export const metadata: Metadata = { metadataBase: new URL("https://example.com"), // 배포 도메인. 상대경로를 절대경로로 바꿔줌 title: { default: "데모 스토어 — 신선한 식재료 마켓", template: "%s | 데모 스토어", // 하위 페이지 제목 뒤에 자동으로 " | 데모 스토어" 붙음 }, description: "Next.js로 만든 SEO 데모 스토어. 서버 렌더링과 메타데이터가 HTML에 실제로 담기는지 확인해 봅니다.", openGraph: { type: "website", siteName: "데모 스토어", locale: "ko_KR", }, }; export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> ); }
여기서 꼭 알아 둘 게 셋 있습니다.
metadataBase— 이걸 설정하지 않으면 OG 이미지나 canonical을/og.png같은 상대경로로 적었을 때 절대경로로 바뀌지 않아, 카카오톡·페이스북 미리보기가 깨집니다. 배포 도메인을 한 번 적어 두면 끝입니다.title.template— 페이지마다상품명 | 데모 스토어처럼 서비스명을 뒤에 붙이고 싶을 때 씁니다. 하위 페이지에서는title: "상품명"만 적으면 자동으로 합쳐집니다.lang="ko"— 한국어 서비스인데lang="en"으로 두는 경우가 많습니다. 검색엔진과 스크린리더가 언어를 잘못 인식하니ko로 바꿉니다.
2. 데이터는 한 요청에 한 번만 조회
상세 페이지는 메타데이터를 만들 때와 본문을 그릴 때 같은 데이터를 씁니다. 같은 데이터를 두 번 조회하지 않도록, 데이터 함수를 React.cache로 감싸 둡니다. 실제 서비스라면 이 안에서 DB(예: Supabase)를 부르겠지만, 데모라 메모리 배열로 뒀습니다.
// lib/products.ts import { cache } from "react"; export type Product = { id: string; name: string; summary: string; description: string; price: number; imageUrl: string; }; const DB: Product[] = [ { id: "1", name: "유기농 방울토마토 1kg", summary: "햇살 가득 머금은 달콤한 방울토마토, 농장 직송으로 신선하게.", description: "당도 높은 유기농 방울토마토를 산지에서 바로 보내드립니다. 샐러드와 간식으로 좋습니다.", price: 8900, imageUrl: "https://images.example.com/tomato.jpg", }, { id: "2", name: "국산 들기름 250ml", summary: "저온압착 방식으로 고소함을 살린 국산 들기름.", description: "국내산 들깨를 저온 압착해 향과 영양을 그대로 담았습니다.", price: 12000, imageUrl: "https://images.example.com/oil.jpg", }, ]; // 한 요청 안에서 한 번만 조회되게 cache로 감쌉니다. export const getProduct = cache(async (id: string): Promise<Product | null> => { await new Promise((r) => setTimeout(r, 10)); // DB 지연 흉내 return DB.find((p) => p.id === id) ?? null; }); export const getAllProductIds = cache(async (): Promise<string[]> => DB.map((p) => p.id)); export const getAllProducts = cache(async (): Promise<Product[]> => DB);
getProduct를 generateMetadata와 페이지 본문에서 둘 다 불러도, cache 덕분에 실제 조회는 한 번입니다. 상세 페이지마다 쿼리가 절반으로 줄어드니, 한 번 들여 둘 만한 습관입니다.
3. 홈과 목록 — 서버 컴포넌트로
홈과 목록은 데이터를 서버에서 받아 그대로 그립니다. "use client"가 없으니 전부 서버 컴포넌트이고, 상품 이름이 HTML에 박혀서 내려갑니다.
// app/page.tsx import Link from "next/link"; import { getAllProducts } from "@/lib/products"; export default async function HomePage() { const products = await getAllProducts(); return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}> <h1>데모 스토어</h1> <p>Next.js의 SEO 기능이 실제 HTML에 담기는지 확인하려고 만든 작은 스토어입니다.</p> <ul> {products.map((p) => ( <li key={p.id}> <Link href={`/products/${p.id}`}>{p.name}</Link> </li> ))} </ul> </main> ); }
목록 페이지에는 페이지 고유의 제목과 설명, 그리고 대표 URL(canonical)을 답니다.
// app/products/page.tsx import type { Metadata } from "next"; import Link from "next/link"; import { getAllProducts } from "@/lib/products"; export const metadata: Metadata = { title: "상품 목록", // → "상품 목록 | 데모 스토어" description: "신선한 식재료 상품을 모아 둔 목록 페이지입니다.", alternates: { canonical: "/products" }, }; export default async function ProductsPage() { const products = await getAllProducts(); return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}> <h1>상품 목록</h1> <ul> {products.map((p) => ( <li key={p.id}> <Link href={`/products/${p.id}`}> {p.name} — {p.price.toLocaleString()}원 </Link> </li> ))} </ul> </main> ); }
canonical은 같은 내용이 여러 주소로 열릴 때(?utm_source=..., ?sort=price 같은 파라미터가 붙을 때) "이 페이지의 진짜 대표 주소는 이거다"를 알려주는 장치입니다. metadataBase를 설정해 뒀으니 상대경로로 적어도 절대경로로 변환됩니다.
4. 상세 페이지 — 동적 메타데이터 + OG + JSON-LD
SEO에서 진짜 중요한 건 상품 상세, 게시글 상세처럼 개수가 많고 URL이 각각 다른 페이지입니다. 제목·설명이 데이터에 따라 달라져야 하므로, 고정 metadata 대신 generateMetadata 함수를 씁니다. 같은 파일에 본문도 함께 두고, 본문에서는 JSON-LD 구조화 데이터까지 심었습니다.
// app/products/[id]/page.tsx import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getProduct } from "@/lib/products"; type Props = { params: Promise<{ id: string }> }; export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params; // Next 15+부터 params는 Promise라 await 필요 const product = await getProduct(id); if (!product) { return { title: "상품을 찾을 수 없습니다", robots: { index: false } }; } const title = product.name; const description = product.summary.slice(0, 120); return { title, description, alternates: { canonical: `/products/${id}` }, openGraph: { title, description, url: `/products/${id}`, images: [{ url: product.imageUrl, width: 1200, height: 630, alt: product.name }], }, }; } export default async function ProductPage({ params }: Props) { const { id } = await params; const product = await getProduct(id); if (!product) notFound(); // 없는 상품 → 404 (soft-404 방지) // JSON-LD 구조화 데이터 (구글 리치 결과용) const jsonLd = { "@context": "https://schema.org", "@type": "Product", name: product.name, description: product.description, image: product.imageUrl, offers: { "@type": "Offer", price: product.price, priceCurrency: "KRW", availability: "https://schema.org/InStock", }, }; return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <h1>{product.name}</h1> <p>{product.description}</p> <strong>{product.price.toLocaleString()}원</strong> </main> ); }
자주 놓치는 지점을 짚어 둡니다.
params는await해야 합니다. Next 15부터params가 Promise로 바뀌었습니다.params.id로 바로 쓰면 타입 에러가 납니다.- 없는 데이터는
notFound()로 404를 내려야 합니다. 상품이 없는데도 200 OK에 "상품이 없습니다" 화면만 보여주면 구글은 이를 soft-404로 보고 신뢰도를 깎습니다. - JSON-LD는 검색 결과에 가격·재고 같은 정보가 함께 붙어 나오게 해 주는 구조화 데이터입니다.
dangerouslySetInnerHTML이라는 이름이 무섭게 들리지만, 여기서는 우리가 만든 객체를JSON.stringify로 직렬화해 넣는 것뿐이라 안전합니다. 사용자가 입력한 문자열을 그대로 넣지만 않으면 됩니다.
OG(Open Graph)는 링크를 카카오톡이나 슬랙에 붙였을 때 뜨는 썸네일·제목·설명입니다. 한국 서비스는 카카오톡 공유가 많으니 체감이 큽니다. 루트 레이아웃에 기본값을 깔아 두고, 상세에서 제목·이미지만 덮어쓰는 식으로 갑니다.
5. sitemap.xml과 robots.txt
사이트맵은 "우리 사이트에 이런 URL들이 있어요"를 크롤러에게 한 번에 알려주는 파일입니다. App Router에서는 app/sitemap.ts 하나면 됩니다. 정적 페이지는 손으로 적고, 상세 페이지는 데이터에서 목록을 가져와 펼칩니다.
// app/sitemap.ts import type { MetadataRoute } from "next"; import { getAllProductIds } from "@/lib/products"; const BASE = "https://example.com"; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const ids = await getAllProductIds(); const productUrls: MetadataRoute.Sitemap = ids.map((id) => ({ url: `${BASE}/products/${id}`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8, })); return [ { url: BASE, lastModified: new Date(), changeFrequency: "daily", priority: 1 }, { url: `${BASE}/products`, lastModified: new Date(), changeFrequency: "hourly", priority: 0.9 }, ...productUrls, ]; }
robots.ts는 크롤러에게 출입 규칙을 알려줍니다. 비공개 영역은 막고, 사이트맵 주소를 적어 둡니다.
// app/robots.ts import type { MetadataRoute } from "next"; const BASE = "https://example.com"; export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: "*", allow: "/", disallow: ["/mypage/", "/api/"], // 비공개 영역은 크롤 금지 }, sitemap: `${BASE}/sitemap.xml`, }; }
각각 /sitemap.xml, /robots.txt 주소로 자동 생성됩니다. 빌드 없이 dev 서버에서도 확인됩니다. 주의할 점은 마이페이지·관리자·결제처럼 비공개·인증 전용 페이지는 사이트맵에 넣지 않는 것입니다.
6. 비공개 페이지는 색인에서 빼기
마이페이지, 장바구니, 관리자, 결제 결과처럼 로그인해야 보이거나 개인정보가 담긴 페이지는 검색에 나오면 안 됩니다. 이런 페이지에는 robots: { index: false }를 줍니다.
// app/mypage/page.tsx import type { Metadata } from "next"; export const metadata: Metadata = { title: "마이페이지", robots: { index: false, follow: false }, }; export default function MyPage() { return ( <main style={{ maxWidth: 640, margin: "0 auto", padding: 24 }}> <h1>마이페이지</h1> <p>로그인한 사용자만 보는 영역입니다. 검색 색인에서 제외합니다(noindex).</p> </main> ); }
이러면 <meta name="robots" content="noindex, nofollow">가 들어가 검색결과에서 제외됩니다. 반대로 메인·목록·상세 같은 공개 페이지는 별도 설정이 없어도 기본이 색인 허용이라, 굳이 index: true를 적을 필요는 없습니다.
7. OG 이미지를 코드로 그리기
OG 이미지를 따로 디자인하기 번거롭다면, Next가 코드로 이미지를 그려 주는 기능이 있습니다. app/opengraph-image.tsx를 두면 됩니다.
// app/opengraph-image.tsx import { ImageResponse } from "next/og"; export const alt = "데모 스토어"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 96, background: "#16a34a", color: "white", }} > 데모 스토어 </div> ), { ...size }, ); }
이 파일 하나면 Next가 <meta property="og:image">를 알아서 채워 줍니다.
8. 진짜로 됐는지 직접 확인하기
코드를 다 넣었으면 정말 HTML에 들어갔는지 확인합니다. 화면만 보면 모릅니다. 서버가 내려주는 원문을 봐야 합니다. dev 서버를 띄우고,
npm run dev
curl로 받은 원문을 들여다봤습니다. 아래는 실제 출력입니다.
홈(/) — lang="ko", 루트 메타데이터, 코드로 생성된 OG 이미지가 절대경로로 들어갔습니다.
<html lang="ko" ...> <title>데모 스토어 — 신선한 식재료 마켓</title> <meta name="description" content="Next.js로 만든 SEO 데모 스토어. ..."/> <meta property="og:site_name" content="데모 스토어"/> <meta property="og:image" content="http://localhost:3940/opengraph-image?..."/>
상세(/products/1) — title.template가 적용돼 유기농 방울토마토 1kg | 데모 스토어가 됐고, 설명은 데이터에서 왔으며, canonical이 metadataBase 덕에 절대경로로 해석됐습니다.
<title>유기농 방울토마토 1kg | 데모 스토어</title> <meta name="description" content="햇살 가득 머금은 달콤한 방울토마토, 농장 직송으로 신선하게."/> <link rel="canonical" href="https://example.com/products/1"/> <meta property="og:title" content="유기농 방울토마토 1kg"/> <meta property="og:image" content="https://images.example.com/tomato.jpg"/>
상세 본문 — <h1>에 상품명이 박혀서 내려오고(클라이언트 패칭이 아닙니다), JSON-LD가 원문에 포함됐습니다. 글 첫머리에서 말한 "크롤러가 보는 HTML"에 내용이 실제로 들어 있는 겁니다.
<h1>유기농 방울토마토 1kg</h1> <script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"유기농 방울토마토 1kg",...,"offers":{"@type":"Offer","price":8900,"priceCurrency":"KRW","availability":"https://schema.org/InStock"}}</script>
사이트맵·robots·noindex·404도 모두 확인했습니다.
$ curl -s localhost:3940/sitemap.xml <?xml version="1.0" encoding="UTF-8"?> <urlset ...> <url><loc>https://example.com</loc>...</url> <url><loc>https://example.com/products</loc>...</url> <url><loc>https://example.com/products/1</loc>...</url> <url><loc>https://example.com/products/2</loc>...</url> </urlset> $ curl -s localhost:3940/robots.txt User-Agent: * Allow: / Disallow: /mypage/ Disallow: /api/ Sitemap: https://example.com/sitemap.xml $ curl -s localhost:3940/mypage | grep robots <meta name="robots" content="noindex, nofollow"/> $ curl -s -o /dev/null -w "%{http_code}" localhost:3940/products/999 404
마지막으로 프로덕션 빌드도 깨끗한지 확인했습니다.
$ npm run build ✓ Compiled successfully Finished TypeScript ... Route (app) ┌ ○ / ├ ○ /mypage ├ ○ /opengraph-image ├ ○ /products ├ ƒ /products/[id] ├ ○ /robots.txt └ ○ /sitemap.xml ○ (Static) prerendered as static content ƒ (Dynamic) server-rendered on demand
curl로 받은 원문에 제목·설명·상품 이름·JSON-LD가 보이면 크롤러도 그걸 봅니다. 브라우저에서도 **페이지 소스 보기(Ctrl+U)**가 같은 역할을 합니다. 개발자도구의 Elements 탭은 자바스크립트 실행 후의 DOM이라 SEO 확인용으로는 부적절하니, 반드시 소스 보기의 원문을 봐야 합니다. 배포 후에는 구글 Rich Results Test와 Search Console로 실제 색인 상태를 볼 수 있습니다.
정리
빈 프로젝트에서 출발해 붙인 것들을 한 줄로 모으면 이렇습니다.
- 루트
layout.tsx에metadataBase,title.template,description,lang="ko" - 공개 페이지(메인·목록·상세) 데이터는 서버 컴포넌트에서 fetch (
"use client"+useEffect패칭이 아니라) - 목록·상세에
metadata/generateMetadata로 고유 title·description, 그리고canonical - 상세에 OG와 JSON-LD, 없는 데이터는
notFound()로 404 app/sitemap.ts에 공개 URL,app/robots.ts에 sitemap 주소 + 비공개 disallow- 마이페이지·관리자·결제 등 비공개에
robots: { index: false } - 마지막엔
curl로 원문을 직접 확인
별도 라이브러리(react-helmet, next-seo 같은) 없이 Next 16 기본 기능만으로 충분했습니다. 화면이 잘 뜬다고 SEO가 되는 게 아니라, 서버가 내려주는 HTML에 정보가 담겨야 검색에 걸립니다. 결국 그 한 가지를 curl로 직접 확인하는 습관이 제일 중요하다고 느꼈습니다.




댓글
댓글을 작성하려면 이 필요합니다.