diff --git a/app/lib/shared/sitemap.test.ts b/app/lib/shared/sitemap.test.ts
new file mode 100644
index 0000000..5080eff
--- /dev/null
+++ b/app/lib/shared/sitemap.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { buildRobotsTxt, buildSitemapXml } from "./sitemap";
+
+describe("buildSitemapXml", () => {
+ it("パスを origin つきの絶対URLにして loc に入れる", () => {
+ const xml = buildSitemapXml("https://example.com", ["/", "/login"]);
+ expect(xml).toContain("https://example.com/");
+ expect(xml).toContain("https://example.com/login");
+ });
+
+ it("XML の特殊文字をエスケープする", () => {
+ expect(buildSitemapXml("https://example.com", ["/a?x=1&y=2"])).toContain(
+ "https://example.com/a?x=1&y=2",
+ );
+ });
+});
+
+describe("buildRobotsTxt", () => {
+ it("Disallow と sitemap の絶対URLを書く", () => {
+ const txt = buildRobotsTxt("https://example.com", ["/api/"]);
+ expect(txt).toContain("Disallow: /api/");
+ expect(txt).toContain("Sitemap: https://example.com/sitemap.xml");
+ });
+});
diff --git a/app/lib/shared/sitemap.ts b/app/lib/shared/sitemap.ts
new file mode 100644
index 0000000..1cb3668
--- /dev/null
+++ b/app/lib/shared/sitemap.ts
@@ -0,0 +1,42 @@
+// 検索エンジン(Googleのクローラなど)に渡す sitemap.xml・robots.txt を組み立てる。
+// /learn や /my-notebooks などはログイン必須でクローラが読めないため、未ログインで見られるページだけを載せる
+export const SITEMAP_PATHS = ["/", "/login", "/signup"] as const;
+
+// クローラに辿らせないパス。API とログイン必須のページ
+export const ROBOTS_DISALLOW_PATHS = [
+ "/api/",
+ "/history",
+ "/learn",
+ "/my-notebooks",
+ "/invite/",
+] as const;
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+// origin(例: https://example.com)と パスの一覧から sitemap.xml の本文を返す
+export function buildSitemapXml(origin: string, paths: readonly string[]): string {
+ const urls = paths
+ .map((path) => ` ${escapeXml(new URL(path, origin).toString())}`)
+ .join("\n");
+ return `
+
+${urls}
+
+`;
+}
+
+export function buildRobotsTxt(origin: string, disallowPaths: readonly string[]): string {
+ const disallow = disallowPaths.map((path) => `Disallow: ${path}`).join("\n");
+ return `User-agent: *
+${disallow}
+
+Sitemap: ${new URL("/sitemap.xml", origin).toString()}
+`;
+}
diff --git a/app/routes/robots[.]txt/route.ts b/app/routes/robots[.]txt/route.ts
new file mode 100644
index 0000000..e699ac0
--- /dev/null
+++ b/app/routes/robots[.]txt/route.ts
@@ -0,0 +1,15 @@
+import { buildRobotsTxt, ROBOTS_DISALLOW_PATHS } from "@/lib/shared/sitemap";
+
+// 検索エンジン向けの /robots.txt。sitemap.xml の場所もここで知らせる
+export function loader() {
+ const txt = buildRobotsTxt(
+ process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
+ ROBOTS_DISALLOW_PATHS,
+ );
+ return new Response(txt, {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+}
diff --git a/app/routes/sitemap[.]xml/route.ts b/app/routes/sitemap[.]xml/route.ts
new file mode 100644
index 0000000..72a0c47
--- /dev/null
+++ b/app/routes/sitemap[.]xml/route.ts
@@ -0,0 +1,15 @@
+import { buildSitemapXml, SITEMAP_PATHS } from "@/lib/shared/sitemap";
+
+// 検索エンジン向けの /sitemap.xml。アプリのオリジンは認証と同じ環境変数を使う
+export function loader() {
+ const xml = buildSitemapXml(
+ process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
+ SITEMAP_PATHS,
+ );
+ return new Response(xml, {
+ headers: {
+ "Content-Type": "application/xml; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+}