diff --git a/package.json b/package.json index a32def7..1c1857a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.544.0", "next": "15.5.3", + "next-seo": "^6.8.0", "react": "19.1.0", "react-dom": "19.1.0", "tailwind-merge": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 191062e..2e0f6e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: next: specifier: 15.5.3 version: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-seo: + specifier: ^6.8.0 + version: 6.8.0(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -3011,6 +3014,16 @@ packages: integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, } + next-seo@6.8.0: + resolution: + { + integrity: sha512-zcxaV67PFXCSf8e6SXxbxPaOTgc8St/esxfsYXfQXMM24UESUVSXFm7f2A9HMkAwa0Gqn4s64HxYZAGfdF4Vhg==, + } + peerDependencies: + next: ^8.1.1-canary.54 || >=9.0.0 + react: ">=16.0.0" + react-dom: ">=16.0.0" + next@15.5.3: resolution: { @@ -5843,6 +5856,12 @@ snapshots: natural-compare@1.4.0: {} + next-seo@6.8.0(next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + next: 15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + next@15.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: "@next/env": 15.5.3 diff --git a/src/app/lab/noindex/page.tsx b/src/app/lab/noindex/page.tsx new file mode 100644 index 0000000..ca0284f --- /dev/null +++ b/src/app/lab/noindex/page.tsx @@ -0,0 +1,14 @@ +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
내부 테스트 페이지(검색 제외)
; +} diff --git a/src/app/lab/og-preview/page.tsx b/src/app/lab/og-preview/page.tsx new file mode 100644 index 0000000..022d31d --- /dev/null +++ b/src/app/lab/og-preview/page.tsx @@ -0,0 +1,24 @@ +import { withBase } from "@/seo/baseUrl"; // 선택: 절대 URL 유틸 +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "PlanMate 프로모션 Seed", + description: "공유 카드(OG/Twitter) 테스트용 페이지", + openGraph: { + title: "PlanMate 프로모션 Seed", + description: "공유 카드(OG/Twitter) 테스트용 페이지", + images: [ + { + url: withBase("/og/promo-seed.png"), + width: 1200, + height: 630, + alt: "PlanMate 프로모션 Seed", + }, + ], + }, + twitter: { card: "summary_large_image" }, +}; + +export default function Page() { + return
OG/Twitter 카드 테스트 페이지
; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a5eaa6b..3c8ba87 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,53 @@ +// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "./providers"; +import { + DEFAULT_DESCRIPTION, + DEFAULT_TITLE, + LOCALE, + OG_DEFAULT_IMAGE, + SITE_NAME, + SITE_URL, + TITLE_TEMPLATE, +} from "@/seo/constants"; + export const metadata: Metadata = { - title: "Custom Daily Planner", - description: "나만의 맞춤형 플래너를 손쉽게 디자인하고 사용할 수 있는 웹 앱", + // 절대 URL 기준점 (canonical/OG 절대경로 변환에 사용) + metadataBase: new URL(SITE_URL), + + // 전역 타이틀 규칙 + title: { + default: DEFAULT_TITLE, + template: TITLE_TEMPLATE, // 예: "{페이지제목} | MyPlanMate" + }, + + // 전역 설명 + description: DEFAULT_DESCRIPTION, + + // Open Graph 기본값 + openGraph: { + type: "website", + url: "/", // metadataBase 기준으로 절대 URL 처리 + siteName: SITE_NAME, + title: DEFAULT_TITLE, + description: DEFAULT_DESCRIPTION, + images: [ + { + url: OG_DEFAULT_IMAGE, // "/og/og-default.png" → 절대 URL로 자동 변환 + width: 1200, + height: 630, + alt: `${SITE_NAME} 대표 이미지`, + }, + ], + locale: LOCALE, // "ko_KR" + }, + + // Twitter 카드 기본값 + twitter: { + card: "summary_large_image", + }, }; export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -25,6 +68,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) */} + {children} diff --git a/src/seo/baseUrl.ts b/src/seo/baseUrl.ts new file mode 100644 index 0000000..0d5d812 --- /dev/null +++ b/src/seo/baseUrl.ts @@ -0,0 +1,10 @@ +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}`; +}; diff --git a/src/seo/constants.ts b/src/seo/constants.ts new file mode 100644 index 0000000..7bb9538 --- /dev/null +++ b/src/seo/constants.ts @@ -0,0 +1,12 @@ +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로 절대경로 해석 +export const LOCALE = "ko_KR";