Skip to content

Commit f3739db

Browse files
brody-0125claude
andauthored
feat: implement SEO best practices with metadata, sitemap, and structured data (#62)
- Add comprehensive metadata to root layout (Open Graph, Twitter Cards, canonical URL, keywords, robots directives) - Add per-station generateMetadata with unique titles and descriptions for 300+ station detail pages - Add metadata exports for about, archive, and route pages - Create sitemap.ts generating XML sitemap for all pages - Create robots.ts with sitemap reference - Add JSON-LD structured data: WebSite schema on root layout, BreadcrumbList on station pages via next-seo - Handle edge case for station names ending with 역 (e.g. 서울역) https://claude.ai/code/session_01NNQGR2ptdKHnqpDMPdueuK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 779d29e commit f3739db

9 files changed

Lines changed: 231 additions & 11 deletions

File tree

web/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
},
1010
"dependencies": {
1111
"next": "^16.0.0",
12+
"next-seo": "^7.2.0",
1213
"playwright": "^1.58.2",
1314
"react": "^19.0.0",
1415
"react-dom": "^19.0.0"

web/src/app/about/page.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
'use client';
2-
1+
import type { Metadata } from 'next';
32
import Link from 'next/link';
43
import ThemeToggle from '@/components/ThemeToggle';
54

5+
export const metadata: Metadata = {
6+
title: '데이터 출처 및 투명성',
7+
description:
8+
'나들이는 서울 열린 데이터 광장의 공식 API를 통해 지하철 편의시설 실시간 데이터를 제공합니다.',
9+
alternates: { canonical: '/about/' },
10+
openGraph: {
11+
title: '데이터 출처 및 투명성 | 나들이',
12+
description:
13+
'나들이는 서울 열린 데이터 광장의 공식 API를 통해 지하철 편의시설 실시간 데이터를 제공합니다.',
14+
url: '/about/',
15+
},
16+
};
17+
618
export default function AboutPage() {
719
return (
820
<>

web/src/app/archive/page.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
'use client';
2-
1+
import type { Metadata } from 'next';
32
import Link from 'next/link';
43

4+
export const metadata: Metadata = {
5+
title: '데이터 아카이브',
6+
description:
7+
'서울 지하철 이동편의시설 과거 운행 기록을 조회할 수 있는 데이터 아카이브입니다.',
8+
alternates: { canonical: '/archive/' },
9+
openGraph: {
10+
title: '데이터 아카이브 | 나들이',
11+
description:
12+
'서울 지하철 이동편의시설 과거 운행 기록을 조회할 수 있는 데이터 아카이브입니다.',
13+
url: '/archive/',
14+
},
15+
};
16+
517
export default function ArchivePage() {
618
return (
719
<>

web/src/app/layout.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,55 @@ import './globals.css';
88
const recaptchaSiteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
99
const goatcounterCode = process.env.NEXT_PUBLIC_GOATCOUNTER_CODE;
1010

11+
const SITE_URL = 'https://nadeuri.today';
12+
const SITE_NAME = '나들이';
13+
const SITE_DESCRIPTION =
14+
'서울 지하철 엘리베이터, 에스컬레이터 등 교통약자 편의시설의 실시간 운행 상태를 확인하세요. 1~9호선, 우이신설선 300개 이상 역의 편의시설 정보를 제공합니다.';
15+
1116
export const metadata: Metadata = {
12-
title: '나들이 — 오늘은 어디로 가볼까?',
13-
description:
14-
'서울 지하철 엘리베이터, 에스컬레이터 등 교통약자 편의시설의 실시간 운행 상태를 확인하세요.',
17+
metadataBase: new URL(SITE_URL),
18+
title: {
19+
default: '나들이 — 서울 지하철 교통약자 편의시설 실시간 현황',
20+
template: '%s | 나들이',
21+
},
22+
description: SITE_DESCRIPTION,
23+
keywords: [
24+
'서울 지하철',
25+
'교통약자',
26+
'편의시설',
27+
'엘리베이터',
28+
'에스컬레이터',
29+
'실시간',
30+
'장애인',
31+
'휠체어',
32+
'지하철 엘리베이터 고장',
33+
'서울 메트로',
34+
],
35+
alternates: {
36+
canonical: '/',
37+
},
38+
openGraph: {
39+
type: 'website',
40+
siteName: SITE_NAME,
41+
title: '나들이 — 서울 지하철 교통약자 편의시설 실시간 현황',
42+
description: SITE_DESCRIPTION,
43+
url: SITE_URL,
44+
locale: 'ko_KR',
45+
},
46+
twitter: {
47+
card: 'summary',
48+
title: '나들이 — 서울 지하철 교통약자 편의시설 실시간 현황',
49+
description: SITE_DESCRIPTION,
50+
},
51+
robots: {
52+
index: true,
53+
follow: true,
54+
googleBot: {
55+
index: true,
56+
follow: true,
57+
'max-snippet': -1,
58+
},
59+
},
1560
};
1661

1762
export default function RootLayout({
@@ -27,6 +72,19 @@ export default function RootLayout({
2772
href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;600;700;900&family=Noto+Sans+KR:wght@300;400;500;700&family=DM+Mono:wght@300;400;500&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
2873
/>
2974
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
75+
<script
76+
type="application/ld+json"
77+
dangerouslySetInnerHTML={{
78+
__html: JSON.stringify({
79+
'@context': 'https://schema.org',
80+
'@type': 'WebSite',
81+
name: SITE_NAME,
82+
url: SITE_URL,
83+
description: SITE_DESCRIPTION,
84+
inLanguage: 'ko',
85+
}),
86+
}}
87+
/>
3088
</head>
3189
<body className="antialiased min-h-screen flex flex-col font-sans">
3290
{recaptchaSiteKey && (

web/src/app/robots.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { MetadataRoute } from 'next';
2+
3+
export const dynamic = 'force-static';
4+
5+
export default function robots(): MetadataRoute.Robots {
6+
return {
7+
rules: {
8+
userAgent: '*',
9+
allow: '/',
10+
},
11+
sitemap: 'https://nadeuri.today/sitemap.xml',
12+
};
13+
}

web/src/app/route/page.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
'use client';
2-
1+
import type { Metadata } from 'next';
32
import Link from 'next/link';
43

4+
export const metadata: Metadata = {
5+
title: '경로 안내',
6+
description:
7+
'교통약자를 위한 서울 지하철 최적 경로 안내. 엘리베이터, 에스컬레이터 등 편의시설 상태를 고려한 경로를 제공합니다.',
8+
alternates: { canonical: '/route/' },
9+
openGraph: {
10+
title: '경로 안내 | 나들이',
11+
description:
12+
'교통약자를 위한 서울 지하철 최적 경로 안내. 엘리베이터, 에스컬레이터 등 편의시설 상태를 고려한 경로를 제공합니다.',
13+
url: '/route/',
14+
},
15+
};
16+
517
export default function RoutePage() {
618
return (
719
<>

web/src/app/sitemap.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { MetadataRoute } from 'next';
2+
import { STATIONS } from '@/lib/stations';
3+
4+
export const dynamic = 'force-static';
5+
6+
const SITE_URL = 'https://nadeuri.today';
7+
8+
export default function sitemap(): MetadataRoute.Sitemap {
9+
const staticPages: MetadataRoute.Sitemap = [
10+
{
11+
url: SITE_URL,
12+
lastModified: new Date(),
13+
changeFrequency: 'always',
14+
priority: 1.0,
15+
},
16+
{
17+
url: `${SITE_URL}/about/`,
18+
lastModified: new Date(),
19+
changeFrequency: 'monthly',
20+
priority: 0.5,
21+
},
22+
{
23+
url: `${SITE_URL}/archive/`,
24+
lastModified: new Date(),
25+
changeFrequency: 'monthly',
26+
priority: 0.3,
27+
},
28+
{
29+
url: `${SITE_URL}/route/`,
30+
lastModified: new Date(),
31+
changeFrequency: 'monthly',
32+
priority: 0.3,
33+
},
34+
];
35+
36+
const stationPages: MetadataRoute.Sitemap = STATIONS.map((station) => ({
37+
url: `${SITE_URL}/station/${station.code}/`,
38+
lastModified: new Date(),
39+
changeFrequency: 'always' as const,
40+
priority: 0.8,
41+
}));
42+
43+
return [...staticPages, ...stationPages];
44+
}
Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,69 @@
1-
import { STATIONS } from '@/lib/stations';
1+
import type { Metadata } from 'next';
2+
import { STATIONS, getStation } from '@/lib/stations';
3+
import { BreadcrumbJsonLd } from 'next-seo';
24
import StationDetailClient from './StationDetailClient';
35

6+
const LINE_NAMES: Record<string, string> = {
7+
'1': '1호선', '2': '2호선', '3': '3호선', '4': '4호선',
8+
'5': '5호선', '6': '6호선', '7': '7호선', '8': '8호선', '9': '9호선',
9+
S: '우이신설선',
10+
};
11+
12+
function stationLabel(name: string): string {
13+
return name.endsWith('역') ? name : `${name}역`;
14+
}
15+
416
export function generateStaticParams() {
517
return STATIONS.map((s) => ({ code: s.code }));
618
}
719

20+
export async function generateMetadata({
21+
params,
22+
}: {
23+
params: Promise<{ code: string }>;
24+
}): Promise<Metadata> {
25+
const { code } = await params;
26+
const station = getStation(code);
27+
28+
if (!station) {
29+
return { title: '존재하지 않는 역' };
30+
}
31+
32+
const lineText = station.lines.map((l) => LINE_NAMES[l] ?? `${l}호선`).join(' · ');
33+
const title = `${stationLabel(station.name)} 편의시설 현황`;
34+
const description = `${stationLabel(station.name)}(${lineText}) 엘리베이터, 에스컬레이터 등 교통약자 편의시설의 실시간 운행 상태를 확인하세요.`;
35+
36+
return {
37+
title,
38+
description,
39+
alternates: {
40+
canonical: `/station/${code}/`,
41+
},
42+
openGraph: {
43+
title: `${stationLabel(station.name)} 편의시설 현황 | 나들이`,
44+
description,
45+
url: `/station/${code}/`,
46+
},
47+
};
48+
}
49+
850
export default async function StationPage({ params }: { params: Promise<{ code: string }> }) {
951
const { code } = await params;
10-
return <StationDetailClient code={code} />;
52+
const station = getStation(code);
53+
const stationName = station?.name ?? code;
54+
55+
return (
56+
<>
57+
<BreadcrumbJsonLd
58+
items={[
59+
{ name: '홈', item: 'https://nadeuri.today/' },
60+
{
61+
name: `${stationLabel(stationName)}`,
62+
item: `https://nadeuri.today/station/${code}/`,
63+
},
64+
]}
65+
/>
66+
<StationDetailClient code={code} />
67+
</>
68+
);
1169
}

0 commit comments

Comments
 (0)