diff --git a/app/routes/_seo+/robots[.]txt.ts b/app/routes/_seo+/robots[.]txt.ts
new file mode 100644
index 00000000..f252528f
--- /dev/null
+++ b/app/routes/_seo+/robots[.]txt.ts
@@ -0,0 +1,9 @@
+import { generateRobotsTxt } from '@nasa-gcn/remix-seo'
+import { type DataFunctionArgs } from '@remix-run/node'
+import { getDomainUrl } from '#app/utils/misc.tsx'
+
+export function loader({ request }: DataFunctionArgs) {
+ return generateRobotsTxt([
+ { type: 'sitemap', value: `${getDomainUrl(request)}/sitemap.xml` },
+ ])
+}
diff --git a/app/routes/_seo+/sitemap[.]xml.ts b/app/routes/_seo+/sitemap[.]xml.ts
new file mode 100644
index 00000000..d490b0fa
--- /dev/null
+++ b/app/routes/_seo+/sitemap[.]xml.ts
@@ -0,0 +1,14 @@
+import { generateSitemap } from '@nasa-gcn/remix-seo'
+// @ts-expect-error - this does work, though it's not exactly a public API
+import { routes } from '@remix-run/dev/server-build'
+import { type DataFunctionArgs } from '@remix-run/node'
+import { getDomainUrl } from '#app/utils/misc.tsx'
+
+export function loader({ request }: DataFunctionArgs) {
+ return generateSitemap(request, routes, {
+ siteUrl: getDomainUrl(request),
+ headers: {
+ 'Cache-Control': `public, max-age=${60 * 5}`,
+ },
+ })
+}
diff --git a/app/routes/admin+/cache.tsx b/app/routes/admin+/cache.tsx
index 403dc63a..7e836c9b 100644
--- a/app/routes/admin+/cache.tsx
+++ b/app/routes/admin+/cache.tsx
@@ -1,3 +1,4 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import {
Form,
@@ -28,6 +29,10 @@ import {
} from '#app/utils/misc.tsx'
import { requireUserWithRole } from '#app/utils/permissions.ts'
+export const handle: SEOHandle = {
+ getSitemapEntries: () => null,
+}
+
export async function loader({ request }: DataFunctionArgs) {
await requireUserWithRole(request, 'admin')
const searchParams = new URL(request.url).searchParams
diff --git a/app/routes/settings+/profile.change-email.tsx b/app/routes/settings+/profile.change-email.tsx
index 2a94ff99..c1684975 100644
--- a/app/routes/settings+/profile.change-email.tsx
+++ b/app/routes/settings+/profile.change-email.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import * as E from '@react-email/components'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
@@ -19,9 +20,11 @@ import { invariant, useIsPending } from '#app/utils/misc.tsx'
import { redirectWithToast } from '#app/utils/toast.server.ts'
import { EmailSchema } from '#app/utils/user-validation.ts'
import { verifySessionStorage } from '#app/utils/verification.server.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Change Email,
+ getSitemapEntries: () => null,
}
const newEmailAddressSessionKey = 'new-email-address'
diff --git a/app/routes/settings+/profile.connections.tsx b/app/routes/settings+/profile.connections.tsx
index d09565d9..5ad946eb 100644
--- a/app/routes/settings+/profile.connections.tsx
+++ b/app/routes/settings+/profile.connections.tsx
@@ -1,3 +1,4 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import {
json,
type DataFunctionArgs,
@@ -17,7 +18,7 @@ import { requireUserId } from '#app/utils/auth.server.ts'
import { resolveConnectionData } from '#app/utils/connections.server.ts'
import {
ProviderConnectionForm,
- ProviderName,
+ type ProviderName,
ProviderNameSchema,
providerIcons,
providerNames,
@@ -25,9 +26,11 @@ import {
import { prisma } from '#app/utils/db.server.ts'
import { invariantResponse } from '#app/utils/misc.tsx'
import { createToastHeaders } from '#app/utils/toast.server.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Connections,
+ getSitemapEntries: () => null,
}
async function userCanDeleteConnections(userId: string) {
diff --git a/app/routes/settings+/profile.index.tsx b/app/routes/settings+/profile.index.tsx
index 92734338..d97b8999 100644
--- a/app/routes/settings+/profile.index.tsx
+++ b/app/routes/settings+/profile.index.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import { z } from 'zod'
@@ -18,6 +19,10 @@ import { sessionStorage } from '#app/utils/session.server.ts'
import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts'
import { twoFAVerificationType } from './profile.two-factor.tsx'
+export const handle: SEOHandle = {
+ getSitemapEntries: () => null,
+}
+
const ProfileFormSchema = z.object({
name: NameSchema.optional(),
username: UsernameSchema,
diff --git a/app/routes/settings+/profile.password.tsx b/app/routes/settings+/profile.password.tsx
index 5dcf6cf9..de82a77d 100644
--- a/app/routes/settings+/profile.password.tsx
+++ b/app/routes/settings+/profile.password.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import { Form, Link, useActionData } from '@remix-run/react'
import { z } from 'zod'
@@ -16,9 +17,11 @@ import { prisma } from '#app/utils/db.server.ts'
import { useIsPending } from '#app/utils/misc.tsx'
import { redirectWithToast } from '#app/utils/toast.server.ts'
import { PasswordSchema } from '#app/utils/user-validation.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Password,
+ getSitemapEntries: () => null,
}
const ChangePasswordForm = z
diff --git a/app/routes/settings+/profile.password_.create.tsx b/app/routes/settings+/profile.password_.create.tsx
index e5bc636e..befb2cee 100644
--- a/app/routes/settings+/profile.password_.create.tsx
+++ b/app/routes/settings+/profile.password_.create.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import { Form, Link, useActionData } from '@remix-run/react'
import { z } from 'zod'
@@ -11,9 +12,11 @@ import { getPasswordHash, requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { useIsPending } from '#app/utils/misc.tsx'
import { PasswordSchema } from '#app/utils/user-validation.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Password,
+ getSitemapEntries: () => null,
}
const CreatePasswordForm = z
diff --git a/app/routes/settings+/profile.photo.tsx b/app/routes/settings+/profile.photo.tsx
index 5fc95aa6..9b6337f7 100644
--- a/app/routes/settings+/profile.photo.tsx
+++ b/app/routes/settings+/profile.photo.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import {
json,
redirect,
@@ -23,9 +24,11 @@ import {
useDoubleCheck,
useIsPending,
} from '#app/utils/misc.tsx'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Photo,
+ getSitemapEntries: () => null,
}
const MAX_SIZE = 1024 * 1024 * 3 // 3MB
diff --git a/app/routes/settings+/profile.tsx b/app/routes/settings+/profile.tsx
index 09f7df2d..fc4102e7 100644
--- a/app/routes/settings+/profile.tsx
+++ b/app/routes/settings+/profile.tsx
@@ -1,3 +1,4 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, type DataFunctionArgs } from '@remix-run/node'
import { Link, Outlet, useMatches } from '@remix-run/react'
import { z } from 'zod'
@@ -8,8 +9,12 @@ import { prisma } from '#app/utils/db.server.ts'
import { cn, invariantResponse } from '#app/utils/misc.tsx'
import { useUser } from '#app/utils/user.ts'
-export const handle = {
+export const BreadcrumbHandle = z.object({ breadcrumb: z.any() })
+export type BreadcrumbHandle = z.infer
+
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Edit Profile,
+ getSitemapEntries: () => null,
}
export async function loader({ request }: DataFunctionArgs) {
@@ -23,7 +28,7 @@ export async function loader({ request }: DataFunctionArgs) {
}
const BreadcrumbHandleMatch = z.object({
- handle: z.object({ breadcrumb: z.any() }),
+ handle: BreadcrumbHandle,
})
export default function EditUserProfile() {
diff --git a/app/routes/settings+/profile.two-factor.disable.tsx b/app/routes/settings+/profile.two-factor.disable.tsx
index 1b627f86..b6e5d255 100644
--- a/app/routes/settings+/profile.two-factor.disable.tsx
+++ b/app/routes/settings+/profile.two-factor.disable.tsx
@@ -1,3 +1,4 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, type DataFunctionArgs } from '@remix-run/node'
import { useFetcher } from '@remix-run/react'
import { Icon } from '#app/components/ui/icon.tsx'
@@ -7,10 +8,12 @@ import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { useDoubleCheck } from '#app/utils/misc.tsx'
import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
import { twoFAVerificationType } from './profile.two-factor.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Disable,
+ getSitemapEntries: () => null,
}
export async function loader({ request }: DataFunctionArgs) {
diff --git a/app/routes/settings+/profile.two-factor.index.tsx b/app/routes/settings+/profile.two-factor.index.tsx
index 22c92259..bb0cd155 100644
--- a/app/routes/settings+/profile.two-factor.index.tsx
+++ b/app/routes/settings+/profile.two-factor.index.tsx
@@ -1,4 +1,5 @@
import { generateTOTP } from '@epic-web/totp'
+import { SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import { Icon } from '#app/components/ui/icon.tsx'
@@ -8,6 +9,10 @@ import { prisma } from '#app/utils/db.server.ts'
import { twoFAVerificationType } from './profile.two-factor.tsx'
import { twoFAVerifyVerificationType } from './profile.two-factor.verify.tsx'
+export const handle: SEOHandle = {
+ getSitemapEntries: () => null,
+}
+
export async function loader({ request }: DataFunctionArgs) {
const userId = await requireUserId(request)
const verification = await prisma.verification.findUnique({
diff --git a/app/routes/settings+/profile.two-factor.tsx b/app/routes/settings+/profile.two-factor.tsx
index ae3eb614..7f5dfd19 100644
--- a/app/routes/settings+/profile.two-factor.tsx
+++ b/app/routes/settings+/profile.two-factor.tsx
@@ -1,9 +1,12 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { Outlet } from '@remix-run/react'
import { Icon } from '#app/components/ui/icon.tsx'
import { type VerificationTypes } from '#app/routes/_auth+/verify.tsx'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: 2FA,
+ getSitemapEntries: () => null,
}
export const twoFAVerificationType = '2fa' satisfies VerificationTypes
diff --git a/app/routes/settings+/profile.two-factor.verify.tsx b/app/routes/settings+/profile.two-factor.verify.tsx
index 71a4f484..04829828 100644
--- a/app/routes/settings+/profile.two-factor.verify.tsx
+++ b/app/routes/settings+/profile.two-factor.verify.tsx
@@ -1,6 +1,7 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { getTOTPAuthUri } from '@epic-web/totp'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, redirect, type DataFunctionArgs } from '@remix-run/node'
import {
Form,
@@ -18,10 +19,12 @@ import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx'
import { redirectWithToast } from '#app/utils/toast.server.ts'
+import { type BreadcrumbHandle } from './profile.tsx'
import { twoFAVerificationType } from './profile.two-factor.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb: Verify,
+ getSitemapEntries: () => null,
}
const VerifySchema = z.object({
diff --git a/docs/examples.md b/docs/examples.md
index f3313a65..6f6892d3 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -11,10 +11,6 @@ This page links to examples of how to implement some things with the Epic Stack.
by [@kentcdodds](https://github.com/kentcdodds): Using client hints to avoid
content layout shift with `prefers-reduced-motion` and framer motion
animations.
-- [Sitemaps](https://github.com/kentcdodds/epic-stack-with-sitemap) by
- [@kentcdodds](https://github.com/kentcdodds): Automatically generating a
- sitemap and a nice way to handle dynamic routes and customize the sitemap on a
- per-route basis.
- [Cross-site Request Forgery Protection (CSRF)](https://github.com/kentcdodds/epic-stack-with-csrf)
by [@kentcdodds](https://github.com/kentcdodds): An example of the Epic Stack
with CSRF protection on forms.
diff --git a/docs/seo.md b/docs/seo.md
new file mode 100644
index 00000000..341009f9
--- /dev/null
+++ b/docs/seo.md
@@ -0,0 +1,41 @@
+# SEO
+
+Remix has built-in support for setting up `meta` tags on a per-route basis which
+you can read about
+[in the Remix Metadata docs](https://remix.run/docs/en/main/route/meta).
+
+The Epic Stack also has built-in support for `/robots.txt` and `/sitemap.xml`
+via [resource routes](https://remix.run/docs/en/main/guides/resource-routes)
+using [`@nasa-gcn/remix-seo`](https://github.com/nasa-gcn/remix-seo). By
+default, all routes are included in the `sitemap.xml` file, but you can
+configure which routes are included using the `handle` export in the route. Only
+public-facing pages should be included in the `sitemap.xml` file.
+
+Here are two quick examples of how to customize the sitemap on a per-route basis
+from the `@nasa-gcn/remix-seo` docs:
+
+```tsx
+// routes/blog/$blogslug.tsx
+
+export const handle: SEOHandle = {
+ getSitemapEntries: async request => {
+ const blogs = await db.blog.findMany()
+ return blogs.map(blog => {
+ return { route: `/blog/${blog.slug}`, priority: 0.7 }
+ })
+ },
+}
+```
+
+```tsx
+// in your routes/url-that-doesnt-need-sitemap
+import { SEOHandle } from '@nasa-gcn/remix-seo'
+
+export let loader: LoaderFunction = ({ request }) => {
+ /**/
+}
+
+export const handle: SEOHandle = {
+ getSitemapEntries: () => null,
+}
+```
diff --git a/package-lock.json b/package-lock.json
index 1ab884f6..3f4cdb9d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@conform-to/zod": "^0.9.0",
"@epic-web/remember": "^1.0.2",
"@epic-web/totp": "^1.1.1",
+ "@nasa-gcn/remix-seo": "^2.0.0",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "^5.3.1",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -2180,6 +2181,18 @@
"node": ">=18"
}
},
+ "node_modules/@nasa-gcn/remix-seo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@nasa-gcn/remix-seo/-/remix-seo-2.0.0.tgz",
+ "integrity": "sha512-jawoxrjMMbFGgj20d61KblrQNkSFcW2yP7vWWQn2a+eK2J8uYYbw99j2GD7A4XbpQUxy7dg0yvRVsYYJ5uZnLQ==",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "peerDependencies": {
+ "@remix-run/react": "^1.0.0 || ^2.0.0",
+ "@remix-run/server-runtime": "^1.0.0 || ^2.0.0"
+ }
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -11999,8 +12012,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
diff --git a/package.json b/package.json
index af79ba8b..c7a53691 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"@conform-to/zod": "^0.9.0",
"@epic-web/remember": "^1.0.2",
"@epic-web/totp": "^1.1.1",
+ "@nasa-gcn/remix-seo": "^2.0.0",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/client": "^5.3.1",
"@radix-ui/react-checkbox": "^1.0.4",
diff --git a/public/robots.txt b/public/robots.txt
deleted file mode 100644
index c2a49f4f..00000000
--- a/public/robots.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-User-agent: *
-Allow: /