SEO(Search Engine Optimization)는 검색 엔진에서 웹사이트의 가시성과 순위를 높이는 작업을 의미합니다.
검색 엔진이 웹사이트를 더 잘 인식하고 평가할 수 있도록 최적화하면, 자연 검색 결과에서 더 높은 순위를 차지할 수 있으며, 이를 통해 더 많은 사용자 유입을 기대할 수 있습니다.
SEO를 높이기 위해서는 여러 가지 방법이 있습니다.첫째, 메타 태그 설정이 중요합니다.
적절한<title>과<meta description>태그를 사용하면 검색 엔진이 페이지의 내용을 더 쉽게 이해하고, 검색 결과에서 사용자에게 더 매력적으로 보이게 할 수 있습니다.둘째, 정적 사이트 생성(SSG, Static Site Generation)을 활용하는 것이 효과적입니다.
Next.js에서는getStaticProps를 사용하여 미리 생성된 정적 페이지를 제공할 수 있으며, 이는 검색 엔진이 페이지를 빠르게 크롤링하고 색인할 수 있도록 도와줍니다.셋째, 페이지 로딩 속도를 최적화하는 것도 중요한 요소입니다.
이미지 최적화, 코드 분할, 캐싱, CDN(Content Delivery Network) 활용 등을 통해 로딩 속도를 빠르게 하면, 사용자 경험이 개선될 뿐만 아니라 검색 엔진 순위에도 긍정적인 영향을 줍니다.넷째, 키워드 최적화를 신경 써야 합니다.
사용자 검색 의도에 맞는 키워드를 적절히 배치하면 검색 엔진이 해당 페이지를 더 정확하게 인식할 수 있습니다.SEO를 분석하고 최적화하기 위해서는 다양한 도구를 활용할 수 있습니다.
Google Search Console을 사용하면 웹사이트의 검색 성능을 모니터링하고, 색인 상태를 확인할 수 있습니다.
Lighthouse를 이용하면 페이지 속도, 접근성, SEO 점수를 평가하여 개선할 수 있는 요소를 찾을 수 있습니다.
Yoast SEO와 같은 도구는 메타 태그 및 키워드 최적화를 도와줍니다.결론적으로, Next.js의 정적 사이트 생성 기능과 빠른 로딩 속도를 활용하고, 메타 태그와 키워드 최적화를 병행하면 SEO를 효과적으로 향상시킬 수 있습니다.
💡 Next.js SEO는 이 On-page + Technical 부분을 쉽게 제어할 수 있게 도와주는 도구/패턴들의 모음이라고 보면 편하다.
Next.js가 SEO에 강한 이유는 렌더링 전략을 유연하게 선택할 수 있기 때문이다.
Next.js SEO 문서에서는 렌더링 전략을 비교하면서 특히 Static Site Generation(SSG)를 SEO에 가장 유리한 방식으로 소개한다.
<div id="root"></div> 같은 HTML을 보내고, JS가 실행된 후에야 내용이 채워진다.💡 정리하면,
- 검색 유입이 중요한 페이지(랜딩, 블로그, 제품 상세 등) → SSG/ISR/SSR 우선 고려
- 로그인 후 대시보드처럼 SEO 필요 없는 페이지 → CSR 위주로 가도 무방
Next.js App Router(app/)에서는 기존 next/head 대신 Metadata API로 SEO 관련 메타 태그를 관리한다.
메타데이터를 추가하는 방법은 크게 두 가지이다.
정적 metadata 객체 export
// app/(root)/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Phraiz",
template: "%s | Phraiz",
},
description: "AI로 문장을 다듬고 인용을 자동으로 생성해주는 서비스",
};
title.template 덕분에 하위 페이지에서 title: "로그인"만 써도 실제 <title>은 "로그인 | Phraiz" 형태로 자동 생성된다.동적 generateMetadata 함수 export
// app/post/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/apis/post";
type Props = { params: { slug: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
url: `https://example.com/post/${params.slug}`,
images: [{ url: post.ogImageUrl }],
},
};
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug);
...
}
params.slug)와 서버 데이터(getPost)를 기반으로 각각의 글에 맞는 SEO 정보를 설정한다.export const metadata: Metadata = {
title: "AI 문장 변환 - Phraiz",
description: "AI로 문장을 더 자연스럽고 명확하게 바꿔보세요. 학습, 리포트, 업무 메일까지 모두 지원합니다.",
};title: 페이지 내용과 핵심 키워드를 포함해 간결하게description: 1~2문장으로, 사용자가 "이 페이지에 들어가면 무슨 일을 할 수 있는지"를 설명export const metadata: Metadata = {
title: "AI 문장 변환 - Phraiz",
openGraph: {
title: "AI 문장 변환 - Phraiz",
description: "AI로 문장을 더 자연스럽게 바꿔보세요.",
url: "https://www.phraiz.com/paraphrase",
type: "website",
images: [
{
url: "https://www.phraiz.com/og/paraphrase.png",
width: 1200,
height: 630,
alt: "Phraiz AI 문장 변환 페이지 미리보기",
},
],
},
twitter: {
card: "summary_large_image",
title: "AI 문장 변환 - Phraiz",
description: "AI로 문장을 더 자연스럽게 바꿔보세요.",
images: ["https://www.phraiz.com/og/paraphrase.png"],
},
};openGraph: 페이스북, 카카오톡, 슬랙 등 OG 프로토콜 쓰는 서비스들이 주로 읽는 메타 정보twitter: 트위터(현 X) 전용 카드 정보🤔
- 프로토콜이 아예 다르기 때문
- Open Graph(OG)
- 원래 페이스북이 만든 프로토콜
- 태그 형태:
og:title,og:description,og:image등- 지금은 페이스북, 링크드인, 카카오톡, 핀터레스트 등에서 널리 씀
- Twitter Card
- 트위터(X)가 따로 만든 메타데이터 규격
- 태그 형태:
twitter:card,twitter:title,twitter:description,twitter:image등- Open Graph랑 비슷하지만,
card타입(summary, summarylarge_image 등 같은 트위터 전용 속성이 따로 있음➡️ 즉, "링크 미리보기"라는 목적은 같지만, 태그 이름/필드 구성이 다른 두 개의 표준이 있는 것이다.
➡️ 그래서 Next.js도
openGraph→og:*메타태그 세트
twitter:*메타태그 세트
이렇게 프로토콜 단위로 분리해서 필드를 둔 것이다.
- 그냥
openGraph만 쓰면 안될까?
- 트위터 카드 태그가 없으면, 트위터가 Open Graph 태그로 어느 정도 fallback 해준다.
- 하지만, 공식 가이드/SEO 글들에서는 가능하면 Open Graph + Twitter Card 둘 다 명시하라고 권장한다.
- 그 이유는 다음과 같다.
- 트위터 전용 옵션을 써야 하는 경우
card: "summary_large_image",site,creator같은 건 OG에 없는 개념이라 openGraph만으로는 표현이 안 된다.- 트위터에서만 다른 카드 모양/텍스트를 쓰고 싶을 때
- 다른 SNS(카카오톡, 페이스북 등)는 OG용 이미지
- 트위터는 비율/크롭이 잘 보이는 다른 이미지 →
twitter.images에 따로 설정- 이런 커스터마이징을 하기 좋게 Next가 필드를 분리해 둔 것이다.
export const metadata: Metadata = {
robots: {
index: false, // 검색 결과에 노출 금지
follow: false, // 링크도 따라가지 않음
},
};icons: 파비콘/애플 터치 아이콘 설정
icons 필드는 브라우저 탭에 보이는 파비콘, 모바일 홈 화면 아이콘(apple-touch-icon), shortcut icon 등을 한 번에 설정하는 곳이다.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
icons: {
// 일반 파비콘 (브라우저 탭 아이콘)
icon: "/icons/favicon-32x32.png",
// shortcut icon (일부 브라우저에서 사용)
shortcut: "/icons/favicon.ico",
// iOS 홈 화면 추가용 아이콘
apple: "/icons/apple-touch-icon.png",
// 그 외 커스텀 rel
other: [
{
rel: "mask-icon", // Safari pinned tab 등
url: "/icons/safari-pinned-tab.svg",
color: "#5bbad5",
},
],
},
};
대략 이런 HTML이 자동으로 나온다.
<link rel="icon" href="/icons/favicon-32x32.png" />
<link rel="shortcut icon" href="/icons/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg" color="#5bbad5" />
<head>에 <link rel="icon" ...>을 만들어 준다.app/favicon.icoapp/icon.png, app/icon.jpg, app/icon.svgapp/apple-icon.pngalternates: 여러 종류의 대체 URL 정의
canonical: 이 페이지의 대표 URL (<link rel="canonical">)languages: 각 언어/지역별 hreflang 링크 (<link rel="alternate" hreflang="...">)media: 특정 미디어 쿼리용 대체 페이지 (모바일 전용 등)types: RSS 같은 다른 타입의 리소스용 (type="application/rss+xml")예제 1: 기본 canonical만 쓰는 예제
import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: {
canonical: "https://example.com/paraphrase",
},
};
HTML 예시는 대략 다음과 같다.
<link rel="canonical" href="https://example.com/paraphrase" />
canonical은 동일/유사 콘텐츠가 여러 URL에 있을 때 "대표는 이 URL이다"라고 검색 엔진에 알려주는 역할이다.예제 2: canonical + hreflang(languages)
Next.js에서는 hreflang을 직접 쓰는 게 아니라 alternates.languages로 정의하면, 내부적으로 <link rel="alternate" hreflang="...">를 만들어 준다.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
alternates: {
canonical: "/", // https://example.com
languages: {
"en-US": "/en-US",
"de-DE": "/de-DE",
"ko-KR": "/ko",
},
},
};
HTML 예시는 대략 다음과 같다.
<link rel="canonical" href="https://example.com" />
<link rel="alternate" hreflang="en-US" href="https://example.com/en-US" />
<link rel="alternate" hreflang="de-DE" href="https://example.com/de-DE" />
<link rel="alternate" hreflang="ko-KR" href="https://example.com/ko" />
hreflang은 "이 URL은 어떤 언어/지역용 버전인지"를 검색엔진에 알려주는 태그이다.예제 3: media, types까지 같이 쓰는 예제
export const metadata: Metadata = {
alternates: {
canonical: "https://nextjs.org",
languages: {
"en-US": "https://nextjs.org/en-US",
"de-DE": "https://nextjs.org/de-DE",
},
media: {
"only screen and (max-width: 600px)": "https://nextjs.org/mobile",
},
types: {
"application/rss+xml": "https://nextjs.org/rss",
},
},
};
HTML 예시는 대략 다음과 같다.
<link rel="canonical" href="https://nextjs.org" />
<!-- hreflang -->
<link rel="alternate" hreflang="en-US" href="https://nextjs.org/en-US" />
<link rel="alternate" hreflang="de-DE" href="https://nextjs.org/de-DE" />
<!-- media (모바일 전용 대체 페이지) -->
<link
rel="alternate"
media="only screen and (max-width: 600px)"
href="https://nextjs.org/mobile"
/>
<!-- types (RSS 피드) -->
<link
rel="alternate"
type="application/rss+xml"
href="https://nextjs.org/rss"
/>
robots.txt
역할: "어떤 경로는 크롤링해도 되고, 어떤 경로는 하지 마라"를 알려주는 표준 파일
App Router에서 두 가지 방식으로 정의할 수 있다.
정적 파일: app/robots.txt 파일을 직접 만들어두면 해당 내용이 그대로 서빙된다.
동적 생성: app/robots.ts에서 함수로 정의하면 TypeScript 기반으로 동적으로 robots를 생성할 수 있다.
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL!;
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin", "/dashboard"],
},
sitemap: `${baseUrl}/sitemap.xml`
};
}
Next.js가 만들어 내는 robots.txt는 대략 다음과 같다.
User-agent: *
Allow: /
Disallow: /admin
Disallow: /dashboard
Sitemap: https://example.com/sitemap.xml
/admin, /dashboard는 막고, sitemap 위치는 BASE_URL/sitemap.xml이다.sitemap.xml
역할: "우리 사이트에는 이런 URL들이 있고, 최근 변경일/우선순위는 이렇다"를 검색 엔진에 알려주는 파일
App Router에서 두 가지 방식으로 정의할 수 있다.
정적 파일: app/sitemap.xml 고정 파일
동적 파일: app/sitemap.ts에서 함수로 정의하면 DB에서 글 목록을 가져와 sitemap을 자동 생성해준다.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/apis/post";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const postEntries = posts.map((post) => ({
url: `${baseUrl}/post/${post.slug}`,
lastModified: post.updatedAt,
}));
return [
{ url: baseUrl, lastModified: new Date() },
...postEntries,
];
}
Next.js가 만들어 내는 sitemap.xml은 대략 다음과 같다.
<urlset ...>
<url>
<loc>https://example.com</loc>
<lastmod>2025-11-21</lastmod>
</url>
<url>
<loc>https://example.com/post/nextjs-seo-guide</loc>
<lastmod>2025-01-01</lastmod>
</url>
...
</urlset>
Next.js의 파일 기반 라우팅은 SEO 친화적인 URL을 만들기 좋게 설계되어 있다.
/post/123)/post/nextjs-seo-guide)nextjs, seo, guide라는 키워드가 직접 들어 있음 → 페이지 주제를 판단할 때 추가 신호로 사용nextjs-seo-guide-for-beginners 정도까지는 괜찮다./ai-summary-best-practices/post?id=123&lang=ko 대신 /blog/nextjs-seo/blog/nextjs/seo/blog → 블로그 영역/blog/nextjs → Next.js 관련 글 모음/blog/nextjs/seo → 그 안에서 SEO 관련 글/blog/nextjs만 열어 보면, 관련 글 리스트 페이지로 자연스럽게 이어질 수 있다.같은 (혹은 거의 같은) 콘텐츠가 여러 URL로 접근 가능하면 검색엔진이 중복 콘텐츠로 볼 수 있다.
그래서 canonical로 대표 URL을 지정하거나, 파라미터 설계를 신중하게 해야 한다.
/blog?page=1/blog?page=2/blog?page=3/blog?sort=latest/blog?sort=popular/blog/nextjs-seo-guide?utm_source=facebook/blog/nextjs-seo-guide?utm_source=newsletter&utm_medium=emailcanonical로 대표 페이지를 알려주기/blog/nextjs-seo-guide?utm_source=facebook/blog/nextjs-seo-guide?ref=home/blog/nextjs-seo-guide<link rel="canonical" href="https://site.com/blog/nextjs-seo-guide" />를 넣어두면,요즘은 SEO = 콘텐츠 + 성능(Core Web Vitals)이라고 봐도 될 정도로, 성능이 중요한 랭킹 요소가 되었다.
Next.js SEO 관련 글에서도 SSR/SSG뿐 아니라 LCP, CLS, INP 등 성능 지표를 최적화하는 전략이 강조된다.
next/dynamic으로 무거운 컴포넌트를 지연 로딩 ➡️ 초기 JS 줄이기export const revalidate = 60같은 설정으로 ISR 사용 ➡️ 자주 바뀌지 않는 데이터는 캐시