diff --git a/src/pages/community/CommunityPage.tsx b/src/pages/community/CommunityPage.tsx
index 61db3bc..59f90fb 100644
--- a/src/pages/community/CommunityPage.tsx
+++ b/src/pages/community/CommunityPage.tsx
@@ -8,6 +8,7 @@ import {
useCommunityBoard,
} from '@/features/community-board';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
import { ErrorState, Pagination, Skeleton } from '@/shared/ui';
import {
COMMUNITY_POST_GRID_CLASS,
@@ -16,6 +17,10 @@ import {
} from '@/widgets/community-post-grid';
export function CommunityPage() {
+ useSeo({
+ title: '커뮤니티',
+ description: '정책에 대한 질문과 후기, 소소한 이야기를 자유롭게 나누는 청년들의 이야기 공간.',
+ });
const {
query,
category,
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index 9c278d0..5bb8aed 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/entities/user';
import { useMyProfile } from '@/features/my-profile';
import { useRecommendations } from '@/features/policy-recommendation';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
import { ErrorState, Pagination, Skeleton } from '@/shared/ui';
import { HeroBanner } from '@/widgets/hero-banner';
import {
@@ -25,6 +26,11 @@ import { HomeRecommendSection } from './components/HomeRecommendSection';
const HOME_POLICY_COUNT = POLICY_GRID_SKELETON_COUNT;
export function HomePage() {
+ useSeo({
+ title: '나에게 맞는 청년정책 추천',
+ description:
+ '나에게 맞는 청년정책을 쉽고 빠르게 찾아주는 청년정책 맞춤형 서비스. 정책 검색, 맞춤 추천, 신청 관리, 청년 커뮤니티를 한곳에서 만나보세요.',
+ });
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const user = useAuthStore((state) => state.user);
const {
diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx
index db0d743..0d56efd 100644
--- a/src/pages/login/LoginPage.tsx
+++ b/src/pages/login/LoginPage.tsx
@@ -2,8 +2,10 @@ import { useNavigate } from 'react-router';
import { SocialLoginPanel, useOAuthLogin } from '@/features/auth';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
export function LoginPage() {
+ useSeo({ title: '로그인', noindex: true });
const { startOAuthLogin, isRedirecting } = useOAuthLogin();
const navigate = useNavigate();
diff --git a/src/pages/my-liked-posts/MyLikedPostsPage.tsx b/src/pages/my-liked-posts/MyLikedPostsPage.tsx
index dc0c3a4..8caa0bf 100644
--- a/src/pages/my-liked-posts/MyLikedPostsPage.tsx
+++ b/src/pages/my-liked-posts/MyLikedPostsPage.tsx
@@ -1,7 +1,9 @@
import { useLikedCommunityPosts } from '@/features/community-like';
+import { useSeo } from '@/shared/hooks';
import { CommunityPostListPage } from '@/widgets/community-post-list-page';
export function MyLikedPostsPage() {
+ useSeo({ title: '좋아요한 글', noindex: true });
const { likedPosts, isLoading, isError, refetch } = useLikedCommunityPosts();
return (
diff --git a/src/pages/my-posts/MyPostsPage.tsx b/src/pages/my-posts/MyPostsPage.tsx
index ee0723f..e7bb598 100644
--- a/src/pages/my-posts/MyPostsPage.tsx
+++ b/src/pages/my-posts/MyPostsPage.tsx
@@ -1,7 +1,9 @@
import { useMyCommunityPosts } from '@/features/community-my-posts';
+import { useSeo } from '@/shared/hooks';
import { CommunityPostListPage } from '@/widgets/community-post-list-page';
export function MyPostsPage() {
+ useSeo({ title: '내가 작성한 글', noindex: true });
const { posts, isLoading, isError, refetch } = useMyCommunityPosts();
return (
diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx
index f085b8e..2862d44 100644
--- a/src/pages/my/MyPage.tsx
+++ b/src/pages/my/MyPage.tsx
@@ -5,12 +5,14 @@ import { AccountDangerZone, useDeleteAccount, useLogout } from '@/features/auth'
import { useMyProfile } from '@/features/my-profile';
import { useTrackers } from '@/features/policy-tracker';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
import { EmptyState, ErrorState, Skeleton } from '@/shared/ui';
import { LikedCommunityPosts } from '@/widgets/liked-community-posts';
import { MyCommunityPosts } from '@/widgets/my-community-posts';
import { RecentlyViewed } from '@/widgets/recently-viewed';
export function MyPage() {
+ useSeo({ title: '마이페이지', noindex: true });
const user = useAuthStore((state) => state.user);
const {
profile,
diff --git a/src/pages/oauth-callback/OAuthCallbackPage.tsx b/src/pages/oauth-callback/OAuthCallbackPage.tsx
index c889dfb..3c4865d 100644
--- a/src/pages/oauth-callback/OAuthCallbackPage.tsx
+++ b/src/pages/oauth-callback/OAuthCallbackPage.tsx
@@ -2,9 +2,11 @@ import { useNavigate } from 'react-router';
import { useOAuthCallback } from '@/features/auth';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
import { ErrorState, Skeleton } from '@/shared/ui';
export function OAuthCallbackPage() {
+ useSeo({ title: '로그인 처리 중', noindex: true });
const { errorMessage } = useOAuthCallback();
const navigate = useNavigate();
diff --git a/src/pages/profile-setup/ProfileSetupPage.tsx b/src/pages/profile-setup/ProfileSetupPage.tsx
index 06ee011..73ecc70 100644
--- a/src/pages/profile-setup/ProfileSetupPage.tsx
+++ b/src/pages/profile-setup/ProfileSetupPage.tsx
@@ -1,6 +1,9 @@
import { ProfileSetupContainer } from '@/features/profile-setup';
+import { useSeo } from '@/shared/hooks';
export function ProfileSetupPage() {
+ useSeo({ title: '프로필 설정', noindex: true });
+
return (
diff --git a/src/pages/recommend/RecommendPage.tsx b/src/pages/recommend/RecommendPage.tsx
index 3d612ab..3142760 100644
--- a/src/pages/recommend/RecommendPage.tsx
+++ b/src/pages/recommend/RecommendPage.tsx
@@ -8,10 +8,12 @@ import {
useRecommendations,
} from '@/features/policy-recommendation';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
import { EmptyState, Skeleton } from '@/shared/ui';
import { RecommendationFeed } from '@/widgets/recommendation-feed';
export function RecommendPage() {
+ useSeo({ title: '맞춤 추천', noindex: true });
const { recommendations, isFallback, isLoading, isError } = useRecommendations();
// 프로필은 서버가 원본이다 — store 값은 새로고침 후 빈 값이라 여기서 쓰면 안 된다.
const { profile, isLoading: isProfileLoading } = useMyProfile();
diff --git a/src/pages/search/SearchPageContainer.tsx b/src/pages/search/SearchPageContainer.tsx
index d8bb354..e1b345c 100644
--- a/src/pages/search/SearchPageContainer.tsx
+++ b/src/pages/search/SearchPageContainer.tsx
@@ -1,7 +1,12 @@
import { usePolicySearch } from '@/features/policy-search';
+import { useSeo } from '@/shared/hooks';
import { SearchPagePresenter } from './SearchPagePresenter';
export function SearchPageContainer() {
+ useSeo({
+ title: '정책 검색',
+ description: '카테고리, 지역, 마감일로 청년정책을 검색하고 나에게 맞는 정책을 찾아보세요.',
+ });
const searchProps = usePolicySearch();
return
;
diff --git a/src/pages/tracker/TrackerPage.tsx b/src/pages/tracker/TrackerPage.tsx
index e935b64..cd968be 100644
--- a/src/pages/tracker/TrackerPage.tsx
+++ b/src/pages/tracker/TrackerPage.tsx
@@ -1,6 +1,9 @@
import { TrackerContainer } from '@/features/policy-tracker';
+import { useSeo } from '@/shared/hooks';
export function TrackerPage() {
+ useSeo({ title: '신청 관리', noindex: true });
+
return (
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts
index 8543b6c..a09dcaa 100644
--- a/src/shared/constants/index.ts
+++ b/src/shared/constants/index.ts
@@ -5,3 +5,4 @@ export { DEFAULT_ADMIN_PAGE_SIZE } from './pagination';
export { REGIONS, type RegionName } from './regions';
export type { RoutePath } from './routes';
export { buildCommunityDetailPath, buildCommunityEditPath, ROUTES } from './routes';
+export { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL } from './seo';
diff --git a/src/shared/constants/seo.ts b/src/shared/constants/seo.ts
new file mode 100644
index 0000000..2042012
--- /dev/null
+++ b/src/shared/constants/seo.ts
@@ -0,0 +1,3 @@
+export const SITE_URL = 'https://youthpick.samchon.cloud';
+export const SITE_NAME = 'YouthPick';
+export const DEFAULT_OG_IMAGE = `${SITE_URL}/favicon.png`;
diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts
index 0c70757..d016bdb 100644
--- a/src/shared/hooks/index.ts
+++ b/src/shared/hooks/index.ts
@@ -1,4 +1,5 @@
export { useBodyScrollLock } from './useBodyScrollLock';
export { usePagination } from './usePagination';
+export { useSeo } from './useSeo';
export { useSubmittableUrlQuery } from './useSubmittableUrlQuery';
export { ALL_FILTER_VALUE, useUrlFilters } from './useUrlFilters';
diff --git a/src/shared/hooks/useSeo.ts b/src/shared/hooks/useSeo.ts
new file mode 100644
index 0000000..a4337e5
--- /dev/null
+++ b/src/shared/hooks/useSeo.ts
@@ -0,0 +1,57 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router';
+
+import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL } from '@/shared/constants';
+
+interface UseSeoOptions {
+ /** 페이지 고유 제목. 최종 title은 `{title} | ${SITE_NAME}` 형태로 조합된다. */
+ title: string;
+ description?: string;
+ /** 검색엔진 색인에서 제외해야 하는 페이지(로그인 필요 화면, 로그인/콜백 등)면 true. */
+ noindex?: boolean;
+ image?: string;
+}
+
+function upsertMetaTag(attribute: 'name' | 'property', key: string, content: string): void {
+ let element = document.querySelector(`meta[${attribute}="${key}"]`);
+ if (!element) {
+ element = document.createElement('meta');
+ element.setAttribute(attribute, key);
+ document.head.appendChild(element);
+ }
+ element.setAttribute('content', content);
+}
+
+function upsertCanonicalLink(href: string): void {
+ let element = document.querySelector('link[rel="canonical"]');
+ if (!element) {
+ element = document.createElement('link');
+ element.setAttribute('rel', 'canonical');
+ document.head.appendChild(element);
+ }
+ element.setAttribute('href', href);
+}
+
+// 이 앱은 SSR 없이 CSR로만 동작하는 SPA라 route 전환 시 를 이 hook이 직접 갱신한다.
+// 검색엔진 노출이 필요 없는 화면(로그인 필요 페이지 등)은 noindex를 true로 넘긴다.
+export function useSeo({ title, description, noindex = false, image }: UseSeoOptions): void {
+ const location = useLocation();
+
+ useEffect(() => {
+ const fullTitle = `${title} | ${SITE_NAME}`;
+ document.title = fullTitle;
+
+ upsertMetaTag('name', 'robots', noindex ? 'noindex, nofollow' : 'index, follow');
+
+ if (description) {
+ upsertMetaTag('name', 'description', description);
+ upsertMetaTag('property', 'og:description', description);
+ }
+
+ const canonicalUrl = `${SITE_URL}${location.pathname}`;
+ upsertCanonicalLink(canonicalUrl);
+ upsertMetaTag('property', 'og:url', canonicalUrl);
+ upsertMetaTag('property', 'og:title', fullTitle);
+ upsertMetaTag('property', 'og:image', image ?? DEFAULT_OG_IMAGE);
+ }, [title, description, noindex, image, location.pathname]);
+}
diff --git a/src/widgets/admin-layout/AdminLayout.tsx b/src/widgets/admin-layout/AdminLayout.tsx
index b6ecff3..4879900 100644
--- a/src/widgets/admin-layout/AdminLayout.tsx
+++ b/src/widgets/admin-layout/AdminLayout.tsx
@@ -14,6 +14,7 @@ import {
import { NavLink, Outlet } from 'react-router';
import { ROUTES } from '@/shared/constants';
+import { useSeo } from '@/shared/hooks';
const NAV_SECTIONS = [
{
@@ -41,6 +42,7 @@ const NAV_SECTIONS = [
] as const;
export function AdminLayout() {
+ useSeo({ title: '관리자', noindex: true });
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const isStaleUpdating = isFetching > 0 || isMutating > 0;