├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── README.md ├── TODO.md ├── contentlayer.config.js ├── docker-compose.yml ├── e2e └── example.spec.ts ├── next-sitemap.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.cjs ├── posts └── post-01.md ├── prisma └── schema.prisma ├── public ├── image-404.png ├── js │ └── script.js ├── next.svg ├── robots.txt ├── sitemap-0.xml ├── sitemap.xml ├── thirteen.svg └── vercel.svg ├── src ├── app │ ├── (site) │ │ ├── favicon.ico │ │ └── page.tsx │ ├── blog │ │ ├── [slug] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── server-sitemap-index.xml │ │ └── route.ts │ ├── server-sitemaps │ │ └── [index] │ │ │ └── route.ts │ └── variant │ │ └── [slug] │ │ └── page.tsx ├── components │ ├── Analytics.tsx │ ├── CookieBanner.tsx │ └── TryImage.tsx ├── lib │ ├── db.ts │ ├── helpers.ts │ └── sitemap.ts └── serverless │ └── example.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | SITE_URL='http://localfrost:3000' 2 | DATABASE_URL='postgresql://postgres:postgres@127.0.0.1:5432/test' -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # Public 19 | /public/sitemap*.xml 20 | /public/robots.txt 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | .contentlayer 43 | .env 44 | 45 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "npm run dev", 21 | "serverReadyAction": { 22 | "pattern": "started server on .+, url: (https?://.+)", 23 | "uriFormat": "%s", 24 | "action": "debugWithChrome" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": true 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next 13 Boilerplate for Programmatic SEO Campaigns 2 | 3 | A terse template for creating modern [Programmatic SEO](https://unzip.dev/0x003-programmatic-seo/) campaigns. 4 | Including all of the features you would expect in a modern pSEO campaign. 5 | From the creator of [unzip.dev](https://unzip.dev?ref=next-pseo). 6 | 7 | ![image](https://github.com/agamm/pseo-next/assets/1269911/03286ccd-d476-49a7-bd46-3108a33f9ed7) 8 | 9 | 10 | ### Programmatic SEO Features 11 | 12 | - ⚡ Utalizes Next 13 for Static Fast Webpage Loading and Deploy on Vercel. 13 | - 🏗️ Use ISR for regenerating infomration on Vercel during runtime. 14 | - 🗺️ Sitemaps and Robots.txt generated automatically even with ISR regeneration via next-sitemap. 15 | - 🏯 Site, Blog and Variant page have distinct layouts. 16 | - 🤖 SEO metadata, JSON-LD and Open Graph tags with Next SEO. 17 | - 💯 Maximize lighthouse score. 18 | - 🍪 Built in Cookie Banner. 19 | - 📊 Built in Google Analytics. 20 | - 📷 Auto 404 Image place holder component. 21 | 22 | ### Developer experience first: 23 | 24 | - 📦 Prisma (Postgres-compatible) for easy DB interaction. 25 | - 🔥 Type checking [TypeScript](https://www.typescriptlang.org). 26 | - 💎 Integrate with [Tailwind CSS](https://tailwindcss.com). 27 | - ✅ Strict Mode for TypeScript and React 18. 28 | - 💖 Code Formatter with [Prettier](https://prettier.io). 29 | - 💡 Absolute Imports using `@` prefix. 30 | - 🧪 E2E Testing with Playwright. 31 | 32 | 33 | ## Getting Started 34 | 35 | 1. `git clone git@github.com:agamm/pseo-next.git pseo-example` 36 | 2. `npm i` 37 | 3. Start docker (for local postgress DB) 38 | 4. Open `https://localhost:3000/` 39 | 5. Check the `Development section`. 40 | 41 | 4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 42 | 43 | ## Development 44 | 45 | - .env.example -> mv to -> .env (change parameters if needed) 46 | - /src/app/(site) - all of your landing pages go here (Home page, about, terms etc...) 47 | - /posts - Your blog written as Markdown files. 48 | - /src/app/blog - change your blog layout. 49 | - /src/variant/[slug] - This is where you do your Programmatic variants. 50 | 51 | - Would be something like: yoursite.com/hotels/spain-summer-2023 (variant="hotels", slug="spain-summer-2023") 52 | - Remember to uncomment the comments there to actually fetch from your DB. 53 | 54 | - Make sure to look into /src/components (I recommend using everywhere) 55 | 56 | Lastly after fixing all of the `FIXME` comments. Connect your project to Prisma by: 57 | 58 | - Adding your schema in /prisma/schema.prisma 59 | - `npx prisma migrate dev --name init` 60 | - `npm run db` 61 | 62 | ## Deployment 63 | 64 | 1. Connect your repository to Vercel. 65 | 2. Add your env variables to Vercel. 66 | 3. In vercel change the build: 67 | `npm run prod:build` 68 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - Add prisma? 2 | - Add docker + npm run dbl/p 3 | - Add env for prisma 4 | - Add bit/prisma: https://docs.bit.io/docs/connecting-via-prisma 5 | - Add npm for migrations and push + read about them. 6 | - Add select to project [variant] + sitemap [select random() ...] 7 | - - Bundle Analyzer? 8 | 9 | ### Long term 10 | 11 | - Fix npm `--force` issue: https://github.com/vercel/next.js/issues/46667 12 | - Fix sitemap ?index=[num] to server-sitemap-[num].xml 13 | -------------------------------------------------------------------------------- /contentlayer.config.js: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, makeSource } from "contentlayer/source-files"; 2 | 3 | export const Post = defineDocumentType(() => ({ 4 | name: "Post", 5 | filePathPattern: `**/*.md`, 6 | fields: { 7 | slug: { 8 | type: "string", 9 | description: "URL slug", 10 | required: true, 11 | }, 12 | title: { 13 | type: "string", 14 | description: "The title of the post", 15 | required: true, 16 | }, 17 | date: { 18 | type: "date", 19 | description: "The date of the post", 20 | required: true, 21 | }, 22 | }, 23 | computedFields: { 24 | url: { 25 | type: "string", 26 | resolve: (post) => `/blog/${post.slug}`, 27 | }, 28 | }, 29 | })); 30 | 31 | export default makeSource({ 32 | contentDirPath: "posts", 33 | documentTypes: [Post], 34 | }); 35 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Use postgres/example user/password credentials 2 | version: "3.1" 3 | 4 | services: 5 | db: 6 | image: postgres 7 | restart: always 8 | expose: 9 | - 5433 10 | ports: 11 | - "5433:5433" 12 | environment: 13 | POSTGRES_PASSWORD: postgres 14 | POSTGRES_DB: test 15 | -------------------------------------------------------------------------------- /e2e/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | test("should navigate the blog", async ({ page }) => { 4 | await page.goto("/"); 5 | await expect(page.locator("h1")).toContainText("Hello"); 6 | 7 | await page.goto("/blog"); 8 | await expect(page).toHaveURL("/blog"); 9 | const link = await page.$('a[href="/blog/derp-blog"]'); 10 | if (!link) throw new Error("Failed"); 11 | await link.click(); 12 | // await page.waitForNavigation(); 13 | await expect(page.locator("h1")).toContainText("Lorem Ipsum"); 14 | }); 15 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | 3 | /** Without additional '/' on the end, e.g. https://theodorusclarence.com */ 4 | // FIXME: make sure the siteURL is correct 5 | const siteURL = process.env.SITE_URL || "http://localhost:3000"; 6 | 7 | module.exports = { 8 | siteUrl: siteURL, 9 | generateRobotsTxt: true, 10 | sitemapSize: 7000, 11 | changefreq: "daily", 12 | priority: 0.7, 13 | exclude: ["/server*"], 14 | robotsTxtOptions: { 15 | policies: [ 16 | { 17 | userAgent: "*", 18 | disallow: "/", // FIXME: when in production 19 | }, 20 | ], 21 | additionalSitemaps: [ 22 | `${siteURL}/server-sitemap-index.xml`, // Dynamic URLs for sitemap 23 | // `${siteURL}/server-sitemap.xml`, // Dynamic URLs for sitemap 24 | ], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { withContentlayer } = require("next-contentlayer"); 2 | /** 3 | * @type {import('next').NextConfig} 4 | */ 5 | const nextConfig = { 6 | reactStrictMode: true, 7 | experimental: { 8 | appDir: true, 9 | mdxRs: true, 10 | }, 11 | }; 12 | 13 | module.exports = withContentlayer(nextConfig); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pseo-next", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "postbuild": "next-sitemap", 10 | "test:e2e": "playwright test", 11 | "db": "npx dotenv -e .env.local -- prisma migrate dev", 12 | "studio": "npx dotenv -e .env.local -- prisma studio", 13 | "dev": "docker-compose up -d && next dev", 14 | "prod:build": "npx prisma generate && npx prisma migrate deploy && next build" 15 | }, 16 | "dependencies": { 17 | "@types/node": "18.15.3", 18 | "@types/react": "18.0.28", 19 | "@types/react-dom": "18.0.11", 20 | "contentlayer": "^0.3.2", 21 | "date-fns": "^2.29.3", 22 | "eslint": "8.36.0", 23 | "eslint-config-next": "13.2.4", 24 | "next": "13.3.1", 25 | "next-contentlayer": "^0.3.2", 26 | "next-sitemap": "^4.0.7", 27 | "react": "18.2.0", 28 | "react-cookie-consent": "^8.0.1", 29 | "react-dom": "18.2.0", 30 | "typescript": "4.9.5" 31 | }, 32 | "devDependencies": { 33 | "@playwright/test": "^1.31.2", 34 | "autoprefixer": "^10.4.14", 35 | "postcss": "^8.4.23", 36 | "prisma": "^4.13.0", 37 | "tailwindcss": "^3.3.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from "@playwright/test"; 2 | import path from "path"; 3 | 4 | // Use process.env.PORT by default and fallback to port 3000 5 | const siteURL = process.env.SITE_URL || "http://localhost:3000"; 6 | 7 | // Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port 8 | const baseURL = siteURL; 9 | 10 | // Reference: https://playwright.dev/docs/test-configuration 11 | const config: PlaywrightTestConfig = { 12 | // Timeout per test 13 | timeout: 30 * 1000, 14 | // Test directory 15 | testDir: path.join(__dirname, "e2e"), 16 | // If a test fails, retry it additional 2 times 17 | retries: 1, 18 | // Artifacts folder where screenshots, videos, and traces are stored. 19 | outputDir: "test-results/", 20 | 21 | // Run your local dev server before starting the tests: 22 | // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests 23 | webServer: { 24 | command: "npm run dev", 25 | url: baseURL, 26 | timeout: 120 * 1000, 27 | reuseExistingServer: !process.env.CI, 28 | }, 29 | 30 | use: { 31 | // Use baseURL so to make navigations relative. 32 | // More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url 33 | baseURL, 34 | 35 | // Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc. 36 | // More information: https://playwright.dev/docs/trace-viewer 37 | trace: "retry-with-trace", 38 | 39 | // All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context 40 | // contextOptions: { 41 | // ignoreHTTPSErrors: true, 42 | // }, 43 | }, 44 | 45 | projects: [ 46 | { 47 | name: "Desktop Chrome", 48 | use: { 49 | ...devices["Desktop Chrome"], 50 | }, 51 | }, 52 | // { 53 | // name: "Mobile Safari", 54 | // use: devices["iPhone 12"], 55 | // }, 56 | ], 57 | }; 58 | export default config; 59 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /posts/post-01.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Lorem Ipsum 3 | date: 2021-12-24 4 | slug: derp-blog 5 | --- 6 | 7 | Ullamco et nostrud magna commodo nostrud occaecat quis pariatur id ipsum. Ipsum 8 | consequat enim id excepteur consequat nostrud esse esse fugiat dolore. 9 | Reprehenderit occaecat exercitation non cupidatat in eiusmod laborum ex eu 10 | fugiat aute culpa pariatur. Irure elit proident consequat veniam minim ipsum ex 11 | pariatur. 12 | 13 | Mollit nisi cillum exercitation minim officia velit laborum non Lorem 14 | adipisicing dolore. Labore commodo consectetur commodo velit adipisicing irure 15 | dolore dolor reprehenderit aliquip. Reprehenderit cillum mollit eiusmod 16 | excepteur elit ipsum aute pariatur in. Cupidatat ex culpa velit culpa ad non 17 | labore exercitation irure laborum. -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | -------------------------------------------------------------------------------- /public/image-404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agamm/pseo-next/f9891dbf5a73eaabdb047e00ab0d92f8ec1a12c7/public/image-404.png -------------------------------------------------------------------------------- /public/js/script.js: -------------------------------------------------------------------------------- 1 | loadScript( 2 | "https://www.googletagmanager.com/gtag/js?id=YOUR_GOOGLE_ANALYTICS_ID", 3 | () => { 4 | // FIXME 5 | window.dataLayer = window.dataLayer || []; 6 | function gtag() { 7 | dataLayer.push(arguments); 8 | } 9 | gtag("js", new Date()); 10 | 11 | gtag("config", "YOUR_GOOGLE_ANALYTICS_ID"); // FIXME 12 | } 13 | ); 14 | 15 | function loadScript(src, callback) { 16 | let s, r, t; 17 | r = false; 18 | s = document.createElement("script"); 19 | s.type = "text/javascript"; 20 | s.src = src; 21 | s.async = true; 22 | s.onload = s.onreadystatechange = function () { 23 | if (!r && (!this.readyState || this.readyState == "complete")) { 24 | r = true; 25 | callback(); 26 | } 27 | }; 28 | t = document.getElementsByTagName("script")[0]; 29 | t.parentNode.insertBefore(s, t); 30 | } 31 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Disallow: / 4 | 5 | # Host 6 | Host: http://localhost:3000 7 | 8 | # Sitemaps 9 | Sitemap: http://localhost:3000/sitemap.xml 10 | Sitemap: http://localhost:3000/server-sitemap-index.xml 11 | -------------------------------------------------------------------------------- /public/sitemap-0.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://localhost:30002023-03-16T10:33:40.202Zdaily0.7 4 | http://localhost:3000/blog2023-03-16T10:33:40.202Zdaily0.7 5 | http://localhost:3000/blog/derp-blog2023-03-16T10:33:40.202Zdaily0.7 6 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | http://localhost:3000/sitemap-0.xml 4 | http://localhost:3000/server-sitemap-index.xml 5 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/(site)/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agamm/pseo-next/f9891dbf5a73eaabdb047e00ab0d92f8ec1a12c7/src/app/(site)/favicon.ico -------------------------------------------------------------------------------- /src/app/(site)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getIP } from "@/serverless/example"; 2 | import { Inter } from "next/font/google"; 3 | import Link from "next/link"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export default async function Home() { 8 | const myIP = await getIP(); 9 | return ( 10 |
11 |

Hello {myIP} !

12 |

13 | Check:{" "} 14 | 15 | This Programmatic Route 16 | 17 |

18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { format, parseISO } from "date-fns"; 3 | import { allPosts, Post } from "contentlayer/generated"; 4 | 5 | // This generates all teh blog posts paths 6 | export async function generateStaticParams() { 7 | const postSlugs = allPosts.map((post) => post.slug); 8 | 9 | return postSlugs?.map((slug) => ({ 10 | slug, 11 | })); 12 | } 13 | 14 | // Getting a post 15 | async function getPost(slug: string) { 16 | const post = allPosts.find((post) => post.slug === slug); 17 | return post; 18 | } 19 | 20 | export const metadata = { 21 | title: "Post title", 22 | description: "Post description", 23 | }; 24 | 25 | const PostLayout = async ({ params }: { params: { slug: string } }) => { 26 | const { slug } = params; 27 | 28 | const post: Post | undefined = await getPost(slug); 29 | 30 | if (!post) { 31 | return

Post not found.

; 32 | } 33 | 34 | return ( 35 | <> 36 |
37 |
38 | Home 39 |
40 |
41 |

{post.title}

42 | 45 |
46 |
50 |
51 | 52 | ); 53 | }; 54 | 55 | export default PostLayout; 56 | -------------------------------------------------------------------------------- /src/app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { compareDesc, format, parseISO } from "date-fns"; 3 | import { allPosts } from "contentlayer/generated"; 4 | 5 | async function getAllPosts() { 6 | const posts = allPosts.sort((a, b) => { 7 | return compareDesc(new Date(a.date), new Date(b.date)); 8 | }); 9 | return posts; 10 | } 11 | 12 | function PostCard(post: { date: string; url: string; title: string }) { 13 | return ( 14 |
15 | 18 |

19 | {post.title} 20 |

21 |
22 | ); 23 | } 24 | 25 | export const metadata = { 26 | title: "Blog posts", 27 | description: "The latest blog posts.", 28 | }; 29 | 30 | export default async function Home() { 31 | const posts = await getAllPosts(); 32 | 33 | return ( 34 |
35 |

All blog posts

36 | 37 | {posts.map((post, idx) => ( 38 | 39 | ))} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { CookieBanner } from "@/components/CookieBanner"; 2 | import "./globals.css"; 3 | 4 | // FIXME: change this 5 | export const metadata = { 6 | title: "pSEO next template", 7 | description: "A template for programmatic SEO", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return ( 16 | 17 | 18 |
Header
19 |
{children}
20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/server-sitemap-index.xml/route.ts: -------------------------------------------------------------------------------- 1 | import { generateSitemapArray, sitemapMaxItems } from "@/lib/sitemap"; 2 | import { getServerSideSitemapIndex } from "next-sitemap"; 3 | 4 | export async function GET(request: Request) { 5 | // FIXME: make this fetch from the DB 6 | const itemsCount = sitemapMaxItems * 1.5; 7 | 8 | const sitemapArray = generateSitemapArray(itemsCount); 9 | 10 | return getServerSideSitemapIndex(sitemapArray); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/server-sitemaps/[index]/route.ts: -------------------------------------------------------------------------------- 1 | import { getServerSideSitemap, ISitemapField } from "next-sitemap"; 2 | 3 | import { sitemapMaxItems, siteURL } from "@/lib/sitemap"; 4 | import { NextRequest } from "next/server"; 5 | 6 | const variantName = "variant"; 7 | 8 | export async function GET(request: NextRequest) { 9 | const path = request.nextUrl.pathname; 10 | const sitemapIndexRaw = path.split("/")[2].replace(".xml", ""); 11 | const sitemapIndex = parseInt(sitemapIndexRaw ?? "0"); 12 | 13 | // FIXME: get this from the DB 14 | const variantSlugs = Array.from(Array(sitemapMaxItems * 1.5).keys()).map( 15 | (i) => `${variantName}_${i}` 16 | ); 17 | 18 | const start = sitemapMaxItems * sitemapIndex; 19 | const end = start + sitemapMaxItems; 20 | const currentVariants = variantSlugs.slice(start, end); 21 | 22 | const urls: ISitemapField[] = currentVariants.map((slug) => ({ 23 | loc: `${siteURL}/${variantName}/${slug}`, 24 | lastmod: new Date().toISOString(), 25 | })); 26 | 27 | return getServerSideSitemap(urls); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/variant/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | // FIXME: uncomment here to connect to your DB. 2 | // request has `revalidate: 10`. 3 | // async function getData() { 4 | // const res = await fetch('https://...', { next: { revalidate: 10 } }); 5 | // return res.json(); 6 | // } 7 | 8 | // Like getStaticPaths 9 | // export async function generateStaticParams() { 10 | // const variantsUrls = await DB 11 | 12 | // return postSlugs?.map((slug) => ({ 13 | // slug, 14 | // })); 15 | // } 16 | 17 | // SEO metadata 18 | // import type { Metadata } from 'next' 19 | // export async function generateMetadata({ params }): Promise { 20 | // const product = await DB; 21 | // return { title: product.title } 22 | // } 23 | 24 | const VariantLayout = async ({ params }: { params: { slug: string } }) => { 25 | // const data = await getData(); 26 | return

You are currently viewing: {params.slug}

; 27 | }; 28 | 29 | export default VariantLayout; 30 | -------------------------------------------------------------------------------- /src/components/Analytics.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Script from "next/script"; 4 | 5 | export function Analytics() { 6 | return