diff --git a/templates/with-cloudflare-website/.editorconfig b/templates/with-cloudflare-website/.editorconfig new file mode 100644 index 00000000000..d8e085abcb2 --- /dev/null +++ b/templates/with-cloudflare-website/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +max_line_length = null diff --git a/templates/with-cloudflare-website/.env.example b/templates/with-cloudflare-website/.env.example new file mode 100644 index 00000000000..fbcf921a3dc --- /dev/null +++ b/templates/with-cloudflare-website/.env.example @@ -0,0 +1,14 @@ +# A generated secret for Payload (generate with `openssl rand -hex 32`) +PAYLOAD_SECRET=YOUR_SECRET_HERE + +# Used to configure CORS, format links and more. No trailing slash +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 + +# Secret used to authenticate cron jobs +CRON_SECRET=YOUR_CRON_SECRET_HERE + +# Used to validate preview requests +PREVIEW_SECRET=YOUR_SECRET_HERE + +# Optional: Cloudflare environment name for deployment +# CLOUDFLARE_ENV=production diff --git a/templates/with-cloudflare-website/.gitignore b/templates/with-cloudflare-website/.gitignore new file mode 100644 index 00000000000..f91fc8c1a21 --- /dev/null +++ b/templates/with-cloudflare-website/.gitignore @@ -0,0 +1,21 @@ +build +dist / media +node_modules +.DS_Store +.env +.next +.vercel + +# Payload default media upload directory +public/media/ + +public/robots.txt +public/sitemap*.xml + + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/templates/with-cloudflare-website/.npmrc b/templates/with-cloudflare-website/.npmrc new file mode 100644 index 00000000000..5ff455cdd20 --- /dev/null +++ b/templates/with-cloudflare-website/.npmrc @@ -0,0 +1,2 @@ +legacy-peer-deps=true +enable-pre-post-scripts=true diff --git a/templates/with-cloudflare-website/.prettierignore b/templates/with-cloudflare-website/.prettierignore new file mode 100644 index 00000000000..996b10e1585 --- /dev/null +++ b/templates/with-cloudflare-website/.prettierignore @@ -0,0 +1,14 @@ +**/payload-types.ts +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp +**/docs/** +tsconfig.json + diff --git a/templates/with-cloudflare-website/.prettierrc.json b/templates/with-cloudflare-website/.prettierrc.json new file mode 100644 index 00000000000..cb8ee2671df --- /dev/null +++ b/templates/with-cloudflare-website/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": false +} diff --git a/templates/with-cloudflare-website/.vscode/extensions.json b/templates/with-cloudflare-website/.vscode/extensions.json new file mode 100644 index 00000000000..1d7ac851ea8 --- /dev/null +++ b/templates/with-cloudflare-website/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/templates/with-cloudflare-website/.vscode/launch.json b/templates/with-cloudflare-website/.vscode/launch.json new file mode 100644 index 00000000000..572ee15f7fd --- /dev/null +++ b/templates/with-cloudflare-website/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/next/dist/bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithChrome", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + }, + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/templates/with-cloudflare-website/.vscode/settings.json b/templates/with-cloudflare-website/.vscode/settings.json new file mode 100644 index 00000000000..5918b307925 --- /dev/null +++ b/templates/with-cloudflare-website/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "npm.packageManager": "pnpm", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "editor.formatOnSaveMode": "file", + "typescript.tsdk": "node_modules/typescript/lib", + "[javascript][typescript][typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + } +} diff --git a/templates/with-cloudflare-website/README.md b/templates/with-cloudflare-website/README.md new file mode 100644 index 00000000000..5516415753b --- /dev/null +++ b/templates/with-cloudflare-website/README.md @@ -0,0 +1,375 @@ +# Payload Website Template with Cloudflare D1 & R2 + +This is the official [Payload Website Template](https://github.com/payloadcms/payload/blob/main/templates/website) configured for deployment on Cloudflare Workers with D1 (SQLite) database and R2 object storage. + +Use it to power websites, blogs, or portfolios from small to enterprise, all running on Cloudflare's edge network. + +This template is right for you if you are working on: + +- A personal or enterprise-grade website, blog, or portfolio deployed on Cloudflare +- A content publishing platform with a fully featured publication workflow +- Edge-first applications with global distribution +- Exploring the capabilities of Payload with Cloudflare infrastructure + +Core features: + +- [Pre-configured Payload Config](#how-it-works) +- [Cloudflare D1 Database](#cloudflare-d1) +- [Cloudflare R2 Storage](#cloudflare-r2) +- [Authentication](#users-authentication) +- [Access Control](#access-control) +- [Layout Builder](#layout-builder) +- [Draft Preview](#draft-preview) +- [Live Preview](#live-preview) +- [On-demand Revalidation](#on-demand-revalidation) +- [SEO](#seo) +- [Search](#search) +- [Redirects](#redirects) +- [Jobs and Scheduled Publishing](#jobs-and-scheduled-publish) +- [Website](#website) + +## Quick Start + +To spin up this example locally, follow these steps: + +### Prerequisites + +1. Install [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) +2. Login to Cloudflare: `wrangler login` + +### Clone + +Use the `create-payload-app` CLI to clone this template directly to your machine: + +```bash +pnpx create-payload-app my-project -t with-cloudflare-website +``` + +### Cloudflare Setup + +1. Create a D1 database: + + ```bash + wrangler d1 create my-website + ``` + + Copy the `database_id` from the output and update `wrangler.jsonc`. + +2. Create an R2 bucket: + + ```bash + wrangler r2 bucket create my-website + ``` + +3. Update `wrangler.jsonc` with your database and bucket names. + +### Development + +1. First [clone the repo](#clone) if you have not done so already +1. `cd my-project && cp .env.example .env` to copy the example environment variables +1. Update `PAYLOAD_SECRET` in `.env` (generate with `openssl rand -hex 32`) +1. `pnpm install && pnpm dev` to install dependencies and start the dev server +1. open `http://localhost:3000` to open the app in your browser + +That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. + +### Database Migrations + +**Important:** Before deploying to Cloudflare, you must generate and apply database migrations to create all the necessary tables. + +1. Generate migrations (creates SQL files for your schema): + + ```bash + pnpm payload migrate:create + ``` + + This will create migration files in `src/migrations/` based on your collections. + +2. Apply migrations to your remote D1 database: + + ```bash + # Set your environment (production, staging, etc.) + export CLOUDFLARE_ENV=production + + # Push migrations to D1 + pnpm run deploy:database + ``` + + Or manually with wrangler: + + ```bash + wrangler d1 migrations apply D1 --remote --env=production + ``` + +3. For local development, migrations are automatically applied when you run `pnpm dev`. + +> **Note:** If you see "Failed query" errors after deployment, it usually means migrations haven't been applied. Run `pnpm run deploy:database` to fix this. + +### Deployment + +1. Generate migrations (if not done already): + + ```bash + pnpm payload migrate:create + ``` + +2. Set your Cloudflare environment variables in Cloudflare Pages dashboard: + + - `PAYLOAD_SECRET` - Your secret key (generate with `openssl rand -hex 32`) + +3. Deploy: + + ```bash + export CLOUDFLARE_ENV=production + pnpm run deploy + ``` + + This runs `deploy:database` (applies migrations) then `deploy:app` (builds and deploys). + +## Cloudflare D1 + +This template uses [Cloudflare D1](https://developers.cloudflare.com/d1/), a serverless SQLite database, as the database adapter. D1 provides: + +- Automatic global replication +- Edge-first performance +- Zero cold starts +- Generous free tier + +The database is configured in `wrangler.jsonc` and accessed via the `@payloadcms/db-d1-sqlite` adapter. + +## Cloudflare R2 + +Media uploads are stored in [Cloudflare R2](https://developers.cloudflare.com/r2/), Cloudflare's S3-compatible object storage. R2 provides: + +- No egress fees +- Global distribution via Cloudflare's network +- S3-compatible API + +The storage is configured using the `@payloadcms/storage-r2` plugin. + +> Note: Image resizing and focal point features are not available on Cloudflare Workers due to lack of sharp support. Images are stored and served as-is. + +## How it works + +The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways: + +### Collections + +See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality. + +- #### Users (Authentication) + + Users are auth-enabled collections that have access to the admin panel and unpublished content. See [Access Control](#access-control) for more details. + + For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs. + +- #### Posts + + Posts are used to generate blog posts, news articles, or any other type of content that is published over time. All posts are layout builder enabled so you can generate unique layouts for each post using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Posts are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details. + +- #### Pages + + All pages are layout builder enabled so you can generate unique layouts for each page using layout-building blocks, see [Layout Builder](#layout-builder) for more details. Pages are also draft-enabled so you can preview them before publishing them to your website, see [Draft Preview](#draft-preview) for more details. + +- #### Media + + This is the uploads enabled collection used by pages, posts, and projects to contain media like images, videos, downloads, and other assets. Media is stored in Cloudflare R2. + + > Note: Image resizing features are not available on Cloudflare Workers due to lack of sharp support. + +- #### Categories + + A taxonomy used to group posts together. Categories can be nested inside of one another, for example "News > Technology". See the official [Payload Nested Docs Plugin](https://payloadcms.com/docs/plugins/nested-docs) for more details. + +### Globals + +See the [Globals](https://payloadcms.com/docs/configuration/globals) docs for details on how to extend this functionality. + +- `Header` + + The data required by the header on your front-end like nav links. + +- `Footer` + + Same as above but for the footer of your site. + +## Access control + +Basic access control is setup to limit access to various content based based on publishing status. + +- `users`: Users can access the admin panel and create or edit content. +- `posts`: Everyone can access published posts, but only users can create, update, or delete them. +- `pages`: Everyone can access published pages, but only users can create, update, or delete them. + +For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs. + +## Layout Builder + +Create unique page layouts for any type of content using a powerful layout builder. This template comes pre-configured with the following layout building blocks: + +- Hero +- Content +- Media +- Call To Action +- Archive + +Each block is fully designed and built into the front-end website that comes with this template. See [Website](#website) for more details. + +## Lexical editor + +A deep editorial experience that allows complete freedom to focus just on writing content without breaking out of the flow with support for Payload blocks, media, links and other features provided out of the box. See [Lexical](https://payloadcms.com/docs/rich-text/overview) docs. + +## Draft Preview + +All posts and pages are draft-enabled so you can preview them before publishing them to your website. To do this, these collections use [Versions](https://payloadcms.com/docs/configuration/collections#versions) with `drafts` set to `true`. This means that when you create a new post, project, or page, it will be saved as a draft and will not be visible on your website until you publish it. This also means that you can preview your draft before publishing it to your website. To do this, we automatically format a custom URL which redirects to your front-end to securely fetch the draft version of your content. + +Since the front-end of this template is statically generated, this also means that pages, posts, and projects will need to be regenerated as changes are made to published documents. To do this, we use an `afterChange` hook to regenerate the front-end when a document has changed and its `_status` is `published`. + +For more details on how to extend this functionality, see the official [Draft Preview Example](https://github.com/payloadcms/payload/tree/examples/draft-preview). + +## Live preview + +In addition to draft previews you can also enable live preview to view your end resulting page as you're editing content with full support for SSR rendering. See [Live preview docs](https://payloadcms.com/docs/live-preview/overview) for more details. + +## On-demand Revalidation + +We've added hooks to collections and globals so that all of your pages, posts, footer, or header changes will automatically be updated in the frontend via on-demand revalidation supported by Nextjs. + +> Note: if an image has been changed, for example it's been cropped, you will need to republish the page it's used on in order to be able to revalidate the Nextjs image cache. + +## SEO + +This template comes pre-configured with the official [Payload SEO Plugin](https://payloadcms.com/docs/plugins/seo) for complete SEO control from the admin panel. All SEO data is fully integrated into the front-end website that comes with this template. See [Website](#website) for more details. + +## Search + +This template also pre-configured with the official [Payload Search Plugin](https://payloadcms.com/docs/plugins/search) to showcase how SSR search features can easily be implemented into Next.js with Payload. See [Website](#website) for more details. + +## Redirects + +If you are migrating an existing site or moving content to a new URL, you can use the `redirects` collection to create a proper redirect from old URLs to new ones. This will ensure that proper request status codes are returned to search engines and that your users are not left with a broken link. This template comes pre-configured with the official [Payload Redirects Plugin](https://payloadcms.com/docs/plugins/redirects) for complete redirect control from the admin panel. All redirects are fully integrated into the front-end website that comes with this template. See [Website](#website) for more details. + +## Jobs and Scheduled Publish + +We have configured [Scheduled Publish](https://payloadcms.com/docs/versions/drafts#scheduled-publish) which uses the [jobs queue](https://payloadcms.com/docs/jobs-queue/jobs) in order to publish or unpublish your content on a scheduled time. The tasks are run on a cron schedule and can also be run as a separate instance if needed. + +> Note: When deployed on Cloudflare Workers, you can use Cloudflare Cron Triggers for scheduled tasks. + +## Website + +This template includes a beautifully designed, production-ready front-end built with the [Next.js App Router](https://nextjs.org), served right alongside your Payload app in a instance. This makes it so that you can deploy both your backend and website where you need it. + +Core features: + +- [Next.js App Router](https://nextjs.org) +- [TypeScript](https://www.typescriptlang.org) +- [React Hook Form](https://react-hook-form.com) +- [Payload Admin Bar](https://github.com/payloadcms/payload/tree/main/packages/admin-bar) +- [TailwindCSS styling](https://tailwindcss.com/) +- [shadcn/ui components](https://ui.shadcn.com/) +- User Accounts and Authentication +- Fully featured blog +- Publication workflow +- Dark mode +- Pre-made layout building blocks +- SEO +- Search +- Redirects +- Live preview + +### Cache + +Since this template runs on Cloudflare's edge network, caching is handled at the edge. For more details, see the official [Next.js Caching Docs](https://nextjs.org/docs/app/building-your-application/caching) and [Cloudflare Cache documentation](https://developers.cloudflare.com/cache/). + +## Development + +To spin up this example locally, follow the [Quick Start](#quick-start). Then [Seed](#seed) the database with a few pages, posts, and projects. + +### Working with D1 (SQLite) + +D1 and other SQL-based databases follow a strict schema for managing your data. This means that there are a few extra steps compared to working with MongoDB. + +Note that often times when making big schema changes you can run the risk of losing data if you're not manually migrating it. + +#### Local development + +During local development, Wrangler provides a local D1 instance that mirrors your production database schema. Schema changes are applied via migrations. + +#### Migrations + +[Migrations](https://payloadcms.com/docs/database/migrations) are essentially SQL code versions that keeps track of your schema. When deploying with D1 you will need to make sure you create and then run your migrations. + +Locally create a migration: + +```bash +pnpm payload migrate:create +``` + +This creates the migration files you will need to push alongside with your new configuration. + +When deploying, migrations are automatically run as part of the deploy process: + +```bash +pnpm run deploy +``` + +This command will check for any migrations that have not yet been run and try to run them and it will keep a record of migrations that have been run in the database. + +### Seed + +To seed the database with a few pages, posts, and projects you can click the 'seed database' link from the admin panel. + +The seed script will also create a demo user for demonstration purposes only: + +- Demo Author + - Email: `demo-author@payloadcms.com` + - Password: `password` + +> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data. + +## Production + +### Deploying to Cloudflare + +This template is designed to be deployed to Cloudflare Workers. To deploy: + +1. Ensure you have set up your D1 database and R2 bucket (see [Cloudflare Setup](#cloudflare-setup)) + +2. Update `wrangler.jsonc` with your database and bucket configuration + +3. Set your `PAYLOAD_SECRET` environment variable in Cloudflare: + + ```bash + wrangler secret put PAYLOAD_SECRET + ``` + +4. Deploy your application: + ```bash + pnpm run deploy + ``` + +This will: + +- Run database migrations +- Build your Next.js application with OpenNext +- Deploy to Cloudflare Workers + +### Preview Deployment + +To test your deployment locally before going live: + +```bash +pnpm run preview +``` + +### Environment-specific Deployments + +You can configure multiple environments in `wrangler.jsonc` (e.g., staging, production) and deploy to them using: + +```bash +CLOUDFLARE_ENV=staging pnpm run deploy +``` + +## Questions + +If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions). diff --git a/templates/with-cloudflare-website/cloudflare-env.d.ts b/templates/with-cloudflare-website/cloudflare-env.d.ts new file mode 100644 index 00000000000..56eff9b0429 --- /dev/null +++ b/templates/with-cloudflare-website/cloudflare-env.d.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts` +// Run `pnpm run generate:types:cloudflare` to regenerate this file +declare namespace Cloudflare { + interface Env { + R2: R2Bucket + D1: D1Database + ASSETS: Fetcher + } +} +interface CloudflareEnv extends Cloudflare.Env {} diff --git a/templates/with-cloudflare-website/components.json b/templates/with-cloudflare-website/components.json new file mode 100644 index 00000000000..24e429706ae --- /dev/null +++ b/templates/with-cloudflare-website/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/(frontend)/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/utilities/ui" + } +} diff --git a/templates/with-cloudflare-website/eslint.config.mjs b/templates/with-cloudflare-website/eslint.config.mjs new file mode 100644 index 00000000000..7acd77dd1b3 --- /dev/null +++ b/templates/with-cloudflare-website/eslint.config.mjs @@ -0,0 +1,38 @@ +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}) + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: false, + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^(_|ignore)', + }, + ], + }, + }, + { + ignores: ['.next/'], + }, +] + +export default eslintConfig diff --git a/templates/with-cloudflare-website/next-env.d.ts b/templates/with-cloudflare-website/next-env.d.ts new file mode 100644 index 00000000000..1b3be0840f3 --- /dev/null +++ b/templates/with-cloudflare-website/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/templates/with-cloudflare-website/next.config.ts b/templates/with-cloudflare-website/next.config.ts new file mode 100644 index 00000000000..78477c1c363 --- /dev/null +++ b/templates/with-cloudflare-website/next.config.ts @@ -0,0 +1,36 @@ +import { withPayload } from '@payloadcms/next/withPayload' + +import redirects from './redirects.js' + +const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + ...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => { + const url = new URL(item) + + return { + hostname: url.hostname, + protocol: url.protocol.replace(':', '') as 'http' | 'https', + } + }), + ], + }, + webpack: (webpackConfig: any) => { + webpackConfig.resolve.extensionAlias = { + '.cjs': ['.cts', '.cjs'], + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + } + + return webpackConfig + }, + reactStrictMode: true, + redirects, +} + +export default withPayload(nextConfig, { devBundleServerPackages: false }) diff --git a/templates/with-cloudflare-website/open-next.config.ts b/templates/with-cloudflare-website/open-next.config.ts new file mode 100644 index 00000000000..893435b1c33 --- /dev/null +++ b/templates/with-cloudflare-website/open-next.config.ts @@ -0,0 +1,4 @@ +// default open-next.config.ts file created by @opennextjs/cloudflare +import { defineCloudflareConfig } from '@opennextjs/cloudflare/config' + +export default defineCloudflareConfig({}) diff --git a/templates/with-cloudflare-website/package.json b/templates/with-cloudflare-website/package.json new file mode 100644 index 00000000000..30c5cfe1db5 --- /dev/null +++ b/templates/with-cloudflare-website/package.json @@ -0,0 +1,108 @@ +{ + "name": "with-cloudflare-website", + "version": "1.0.0", + "description": "Website template for Payload with Cloudflare D1 and R2", + "license": "MIT", + "type": "module", + "scripts": { + "build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build", + "deploy": "pnpm run deploy:database && pnpm run deploy:app", + "deploy:app": "opennextjs-cloudflare build --env=$CLOUDFLARE_ENV && opennextjs-cloudflare deploy --env=$CLOUDFLARE_ENV", + "deploy:database": "cross-env NODE_ENV=production PAYLOAD_SECRET=ignore payload migrate && wrangler d1 execute D1 --command 'PRAGMA optimize' --env=$CLOUDFLARE_ENV --remote", + "dev": "cross-env NODE_OPTIONS=--no-deprecation next dev", + "devsafe": "rm -rf .next && rm -rf .open-next && cross-env NODE_OPTIONS=--no-deprecation next dev", + "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", + "generate:types": "pnpm run generate:types:cloudflare && pnpm run generate:types:payload", + "generate:types:cloudflare": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + "generate:types:payload": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", + "ii": "pnpm install --ignore-workspace", + "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", + "lint:fix": "cross-env NODE_OPTIONS=--no-deprecation next lint --fix", + "migrate:create": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:create", + "migrate:fresh": "cross-env NODE_ENV=production PAYLOAD_SECRET=ignore payload migrate:fresh --env=$CLOUDFLARE_ENV", + "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", + "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview --env=$CLOUDFLARE_ENV", + "reinstall": "cross-env NODE_OPTIONS=--no-deprecation rm -rf node_modules && rm pnpm-lock.yaml && pnpm --ignore-workspace install", + "start": "cross-env NODE_OPTIONS=--no-deprecation next start", + "test": "pnpm run test:int && pnpm run test:e2e", + "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --no-experimental-strip-types\" pnpm exec playwright test --config=playwright.config.ts", + "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" + }, + "dependencies": { + "@opennextjs/cloudflare": "^1.11.0", + "@payloadcms/admin-bar": "3.63.0", + "@payloadcms/db-d1-sqlite": "3.63.0", + "@payloadcms/live-preview-react": "3.63.0", + "@payloadcms/next": "3.63.0", + "@payloadcms/plugin-form-builder": "3.63.0", + "@payloadcms/plugin-nested-docs": "3.63.0", + "@payloadcms/plugin-redirects": "3.63.0", + "@payloadcms/plugin-search": "3.63.0", + "@payloadcms/plugin-seo": "3.63.0", + "@payloadcms/richtext-lexical": "3.63.0", + "@payloadcms/storage-r2": "3.63.0", + "@payloadcms/ui": "3.63.0", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cross-env": "^7.0.3", + "dotenv": "16.4.7", + "geist": "^1.3.0", + "graphql": "^16.8.2", + "lucide-react": "^0.378.0", + "next": "15.4.8", + "next-sitemap": "^4.2.3", + "payload": "3.63.0", + "prism-react-renderer": "^2.3.1", + "react": "19.2.1", + "react-dom": "19.2.1", + "react-hook-form": "7.45.4", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "1.56.1", + "@tailwindcss/typography": "^0.5.13", + "@testing-library/react": "16.3.0", + "@types/escape-html": "^1.0.2", + "@types/node": "22.5.4", + "@types/react": "19.2.1", + "@types/react-dom": "19.2.1", + "@vitejs/plugin-react": "4.5.2", + "autoprefixer": "^10.4.19", + "copyfiles": "^2.4.1", + "eslint": "^9.16.0", + "eslint-config-next": "15.4.7", + "jsdom": "26.1.0", + "playwright": "1.56.1", + "playwright-core": "1.56.1", + "postcss": "^8.4.38", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.3", + "typescript": "5.7.3", + "vite-tsconfig-paths": "5.1.4", + "vitest": "3.2.3", + "wrangler": "~4.46.0" + }, + "engines": { + "node": "^18.20.2 || >=20.9.0", + "pnpm": "^9 || ^10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "unrs-resolver" + ] + }, + "cloudflare": { + "bindings": { + "PAYLOAD_SECRET": { + "description": "Generate a random string using `openssl rand -hex 32`" + } + } + } +} diff --git a/templates/with-cloudflare-website/playwright.config.ts b/templates/with-cloudflare-website/playwright.config.ts new file mode 100644 index 00000000000..c60fa9dcfab --- /dev/null +++ b/templates/with-cloudflare-website/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import 'dotenv/config' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], channel: 'chromium' }, + }, + ], + webServer: { + command: 'pnpm dev', + reuseExistingServer: true, + url: 'http://localhost:3000', + }, +}) diff --git a/templates/with-cloudflare-website/postcss.config.js b/templates/with-cloudflare-website/postcss.config.js new file mode 100644 index 00000000000..393a10f832b --- /dev/null +++ b/templates/with-cloudflare-website/postcss.config.js @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config diff --git a/templates/with-cloudflare-website/public/favicon.ico b/templates/with-cloudflare-website/public/favicon.ico new file mode 100644 index 00000000000..1ec2c191e9b Binary files /dev/null and b/templates/with-cloudflare-website/public/favicon.ico differ diff --git a/templates/with-cloudflare-website/public/favicon.svg b/templates/with-cloudflare-website/public/favicon.svg new file mode 100644 index 00000000000..d7ccc5aa98f --- /dev/null +++ b/templates/with-cloudflare-website/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/templates/with-cloudflare-website/public/website-template-OG.webp b/templates/with-cloudflare-website/public/website-template-OG.webp new file mode 100644 index 00000000000..ceb0efc471c Binary files /dev/null and b/templates/with-cloudflare-website/public/website-template-OG.webp differ diff --git a/templates/with-cloudflare-website/redirects.js b/templates/with-cloudflare-website/redirects.js new file mode 100644 index 00000000000..21b76ecc1b8 --- /dev/null +++ b/templates/with-cloudflare-website/redirects.js @@ -0,0 +1,20 @@ +const redirects = async () => { + const internetExplorerRedirect = { + destination: '/ie-incompatible.html', + has: [ + { + type: 'header', + key: 'user-agent', + value: '(.*Trident.*)', // all ie browsers + }, + ], + permanent: false, + source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page + } + + const redirects = [internetExplorerRedirect] + + return redirects +} + +export default redirects diff --git a/templates/with-cloudflare-website/src/Footer/Component.tsx b/templates/with-cloudflare-website/src/Footer/Component.tsx new file mode 100644 index 00000000000..868908fa8a1 --- /dev/null +++ b/templates/with-cloudflare-website/src/Footer/Component.tsx @@ -0,0 +1,34 @@ +import { getCachedGlobal } from '@/utilities/getGlobals' +import Link from 'next/link' +import React from 'react' + +import type { Footer } from '@/payload-types' + +import { ThemeSelector } from '@/providers/Theme/ThemeSelector' +import { CMSLink } from '@/components/Link' +import { Logo } from '@/components/Logo/Logo' + +export async function Footer() { + const footerData: Footer = await getCachedGlobal('footer', 1)() + + const navItems = footerData?.navItems || [] + + return ( +
+
+ + + + +
+ + +
+
+
+ ) +} diff --git a/templates/with-cloudflare-website/src/Footer/RowLabel.tsx b/templates/with-cloudflare-website/src/Footer/RowLabel.tsx new file mode 100644 index 00000000000..a6f949459c6 --- /dev/null +++ b/templates/with-cloudflare-website/src/Footer/RowLabel.tsx @@ -0,0 +1,13 @@ +'use client' +import { Header } from '@/payload-types' +import { RowLabelProps, useRowLabel } from '@payloadcms/ui' + +export const RowLabel: React.FC = () => { + const data = useRowLabel[number]>() + + const label = data?.data?.link?.label + ? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}` + : 'Row' + + return
{label}
+} diff --git a/templates/with-cloudflare-website/src/Footer/config.ts b/templates/with-cloudflare-website/src/Footer/config.ts new file mode 100644 index 00000000000..ca9b54bc462 --- /dev/null +++ b/templates/with-cloudflare-website/src/Footer/config.ts @@ -0,0 +1,32 @@ +import type { GlobalConfig } from 'payload' + +import { link } from '@/fields/link' +import { revalidateFooter } from './hooks/revalidateFooter' + +export const Footer: GlobalConfig = { + slug: 'footer', + access: { + read: () => true, + }, + fields: [ + { + name: 'navItems', + type: 'array', + fields: [ + link({ + appearances: false, + }), + ], + maxRows: 6, + admin: { + initCollapsed: true, + components: { + RowLabel: '@/Footer/RowLabel#RowLabel', + }, + }, + }, + ], + hooks: { + afterChange: [revalidateFooter], + }, +} diff --git a/templates/with-cloudflare-website/src/Footer/hooks/revalidateFooter.ts b/templates/with-cloudflare-website/src/Footer/hooks/revalidateFooter.ts new file mode 100644 index 00000000000..df3acec0273 --- /dev/null +++ b/templates/with-cloudflare-website/src/Footer/hooks/revalidateFooter.ts @@ -0,0 +1,13 @@ +import type { GlobalAfterChangeHook } from 'payload' + +import { revalidateTag } from 'next/cache' + +export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => { + if (!context.disableRevalidate) { + payload.logger.info(`Revalidating footer`) + + revalidateTag('global_footer') + } + + return doc +} diff --git a/templates/with-cloudflare-website/src/Header/Component.client.tsx b/templates/with-cloudflare-website/src/Header/Component.client.tsx new file mode 100644 index 00000000000..a4bd6584b76 --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/Component.client.tsx @@ -0,0 +1,42 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import React, { useEffect, useState } from 'react' + +import type { Header } from '@/payload-types' + +import { Logo } from '@/components/Logo/Logo' +import { HeaderNav } from './Nav' + +interface HeaderClientProps { + data: Header +} + +export const HeaderClient: React.FC = ({ data }) => { + /* Storing the value in a useState to avoid hydration errors */ + const [theme, setTheme] = useState(null) + const { headerTheme, setHeaderTheme } = useHeaderTheme() + const pathname = usePathname() + + useEffect(() => { + setHeaderTheme(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]) + + useEffect(() => { + if (headerTheme && headerTheme !== theme) setTheme(headerTheme) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [headerTheme]) + + return ( +
+
+ + + + +
+
+ ) +} diff --git a/templates/with-cloudflare-website/src/Header/Component.tsx b/templates/with-cloudflare-website/src/Header/Component.tsx new file mode 100644 index 00000000000..02d2e644352 --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/Component.tsx @@ -0,0 +1,11 @@ +import { HeaderClient } from './Component.client' +import { getCachedGlobal } from '@/utilities/getGlobals' +import React from 'react' + +import type { Header } from '@/payload-types' + +export async function Header() { + const headerData: Header = await getCachedGlobal('header', 1)() + + return +} diff --git a/templates/with-cloudflare-website/src/Header/Nav/index.tsx b/templates/with-cloudflare-website/src/Header/Nav/index.tsx new file mode 100644 index 00000000000..070fcca91dd --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/Nav/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' + +import type { Header as HeaderType } from '@/payload-types' + +import { CMSLink } from '@/components/Link' +import Link from 'next/link' +import { SearchIcon } from 'lucide-react' + +export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => { + const navItems = data?.navItems || [] + + return ( + + ) +} diff --git a/templates/with-cloudflare-website/src/Header/RowLabel.tsx b/templates/with-cloudflare-website/src/Header/RowLabel.tsx new file mode 100644 index 00000000000..a6f949459c6 --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/RowLabel.tsx @@ -0,0 +1,13 @@ +'use client' +import { Header } from '@/payload-types' +import { RowLabelProps, useRowLabel } from '@payloadcms/ui' + +export const RowLabel: React.FC = () => { + const data = useRowLabel[number]>() + + const label = data?.data?.link?.label + ? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}` + : 'Row' + + return
{label}
+} diff --git a/templates/with-cloudflare-website/src/Header/config.ts b/templates/with-cloudflare-website/src/Header/config.ts new file mode 100644 index 00000000000..58fe89c809f --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/config.ts @@ -0,0 +1,32 @@ +import type { GlobalConfig } from 'payload' + +import { link } from '@/fields/link' +import { revalidateHeader } from './hooks/revalidateHeader' + +export const Header: GlobalConfig = { + slug: 'header', + access: { + read: () => true, + }, + fields: [ + { + name: 'navItems', + type: 'array', + fields: [ + link({ + appearances: false, + }), + ], + maxRows: 6, + admin: { + initCollapsed: true, + components: { + RowLabel: '@/Header/RowLabel#RowLabel', + }, + }, + }, + ], + hooks: { + afterChange: [revalidateHeader], + }, +} diff --git a/templates/with-cloudflare-website/src/Header/hooks/revalidateHeader.ts b/templates/with-cloudflare-website/src/Header/hooks/revalidateHeader.ts new file mode 100644 index 00000000000..efe176aa885 --- /dev/null +++ b/templates/with-cloudflare-website/src/Header/hooks/revalidateHeader.ts @@ -0,0 +1,13 @@ +import type { GlobalAfterChangeHook } from 'payload' + +import { revalidateTag } from 'next/cache' + +export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => { + if (!context.disableRevalidate) { + payload.logger.info(`Revalidating header`) + + revalidateTag('global_header') + } + + return doc +} diff --git a/templates/with-cloudflare-website/src/access/anyone.ts b/templates/with-cloudflare-website/src/access/anyone.ts new file mode 100644 index 00000000000..bf37c3a1157 --- /dev/null +++ b/templates/with-cloudflare-website/src/access/anyone.ts @@ -0,0 +1,3 @@ +import type { Access } from 'payload' + +export const anyone: Access = () => true diff --git a/templates/with-cloudflare-website/src/access/authenticated.ts b/templates/with-cloudflare-website/src/access/authenticated.ts new file mode 100644 index 00000000000..e2dc34d2a8c --- /dev/null +++ b/templates/with-cloudflare-website/src/access/authenticated.ts @@ -0,0 +1,9 @@ +import type { AccessArgs } from 'payload' + +import type { User } from '@/payload-types' + +type isAuthenticated = (args: AccessArgs) => boolean + +export const authenticated: isAuthenticated = ({ req: { user } }) => { + return Boolean(user) +} diff --git a/templates/with-cloudflare-website/src/access/authenticatedOrPublished.ts b/templates/with-cloudflare-website/src/access/authenticatedOrPublished.ts new file mode 100644 index 00000000000..e49198fbaaa --- /dev/null +++ b/templates/with-cloudflare-website/src/access/authenticatedOrPublished.ts @@ -0,0 +1,13 @@ +import type { Access } from 'payload' + +export const authenticatedOrPublished: Access = ({ req: { user } }) => { + if (user) { + return true + } + + return { + _status: { + equals: 'published', + }, + } +} diff --git a/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts b/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts new file mode 100644 index 00000000000..c73e1ea7559 --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts @@ -0,0 +1,68 @@ +import { getServerSideSitemap } from 'next-sitemap' +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' + +const getPagesSitemap = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const SITE_URL = + process.env.NEXT_PUBLIC_SERVER_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + 'https://example.com' + + const results = await payload.find({ + collection: 'pages', + overrideAccess: false, + draft: false, + depth: 0, + limit: 1000, + pagination: false, + where: { + _status: { + equals: 'published', + }, + }, + select: { + slug: true, + updatedAt: true, + }, + }) + + const dateFallback = new Date().toISOString() + + const defaultSitemap = [ + { + loc: `${SITE_URL}/search`, + lastmod: dateFallback, + }, + { + loc: `${SITE_URL}/posts`, + lastmod: dateFallback, + }, + ] + + const sitemap = results.docs + ? results.docs + .filter((page) => Boolean(page?.slug)) + .map((page) => { + return { + loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`, + lastmod: page.updatedAt || dateFallback, + } + }) + : [] + + return [...defaultSitemap, ...sitemap] + }, + ['pages-sitemap'], + { + tags: ['pages-sitemap'], + }, +) + +export async function GET() { + const sitemap = await getPagesSitemap() + + return getServerSideSitemap(sitemap) +} diff --git a/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts new file mode 100644 index 00000000000..0716abbc2c8 --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -0,0 +1,55 @@ +import { getServerSideSitemap } from 'next-sitemap' +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' + +const getPostsSitemap = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const SITE_URL = + process.env.NEXT_PUBLIC_SERVER_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + 'https://example.com' + + const results = await payload.find({ + collection: 'posts', + overrideAccess: false, + draft: false, + depth: 0, + limit: 1000, + pagination: false, + where: { + _status: { + equals: 'published', + }, + }, + select: { + slug: true, + updatedAt: true, + }, + }) + + const dateFallback = new Date().toISOString() + + const sitemap = results.docs + ? results.docs + .filter((post) => Boolean(post?.slug)) + .map((post) => ({ + loc: `${SITE_URL}/posts/${post?.slug}`, + lastmod: post.updatedAt || dateFallback, + })) + : [] + + return sitemap + }, + ['posts-sitemap'], + { + tags: ['posts-sitemap'], + }, +) + +export async function GET() { + const sitemap = await getPostsSitemap() + + return getServerSideSitemap(sitemap) +} diff --git a/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.client.tsx b/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.client.tsx new file mode 100644 index 00000000000..2d526692800 --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.tsx b/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.tsx new file mode 100644 index 00000000000..183a9b136a3 --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/[slug]/page.tsx @@ -0,0 +1,92 @@ +import type { Metadata } from 'next' + +import { PayloadRedirects } from '@/components/PayloadRedirects' +import configPromise from '@payload-config' +import { getPayload, type RequiredDataFromCollectionSlug } from 'payload' +import { draftMode } from 'next/headers' +import React, { cache } from 'react' +import { homeStatic } from '@/endpoints/seed/home-static' + +import { RenderBlocks } from '@/blocks/RenderBlocks' +import { RenderHero } from '@/heros/RenderHero' +import { generateMeta } from '@/utilities/generateMeta' +import PageClient from './page.client' +import { LivePreviewListener } from '@/components/LivePreviewListener' + +// Cloudflare Workers don't support static generation at build time +export const dynamic = 'force-dynamic' + +type Args = { + params: Promise<{ + slug?: string + }> +} + +export default async function Page({ params: paramsPromise }: Args) { + const { isEnabled: draft } = await draftMode() + const { slug = 'home' } = await paramsPromise + // Decode to support slugs with special characters + const decodedSlug = decodeURIComponent(slug) + const url = '/' + decodedSlug + let page: RequiredDataFromCollectionSlug<'pages'> | null + + page = await queryPageBySlug({ + slug: decodedSlug, + }) + + // Remove this code once your website is seeded + if (!page && slug === 'home') { + page = homeStatic + } + + if (!page) { + return + } + + const { hero, layout } = page + + return ( +
+ + {/* Allows redirects for valid pages too */} + + + {draft && } + + + +
+ ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise { + const { slug = 'home' } = await paramsPromise + // Decode to support slugs with special characters + const decodedSlug = decodeURIComponent(slug) + const page = await queryPageBySlug({ + slug: decodedSlug, + }) + + return generateMeta({ doc: page }) +} + +const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { + const { isEnabled: draft } = await draftMode() + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'pages', + draft, + limit: 1, + pagination: false, + overrideAccess: draft, + where: { + slug: { + equals: slug, + }, + }, + }) + + return result.docs?.[0] || null +}) diff --git a/templates/with-cloudflare-website/src/app/(frontend)/globals.css b/templates/with-cloudflare-website/src/app/(frontend)/globals.css new file mode 100644 index 00000000000..2c785c1da08 --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/globals.css @@ -0,0 +1,103 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: unset; + font-weight: unset; + } + + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 240 5% 96%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 240 6% 80%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.2rem; + + --success: 196 52% 74%; + --warning: 34 89% 85%; + --error: 10 100% 86%; + } + + [data-theme='dark'] { + --background: 0 0% 0%; + --foreground: 210 40% 98%; + + --card: 0 0% 4%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 0, 0%, 15%, 0.8; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --success: 196 100% 14%; + --warning: 34 51% 25%; + --error: 10 39% 43%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground min-h-[100vh] flex flex-col; + } +} + +html { + opacity: 0; +} + +html[data-theme='dark'], +html[data-theme='light'] { + opacity: initial; +} diff --git a/templates/with-cloudflare-website/src/app/(frontend)/layout.tsx b/templates/with-cloudflare-website/src/app/(frontend)/layout.tsx new file mode 100644 index 00000000000..97f7702e10d --- /dev/null +++ b/templates/with-cloudflare-website/src/app/(frontend)/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from 'next' + +import { cn } from '@/utilities/ui' +import { GeistMono } from 'geist/font/mono' +import { GeistSans } from 'geist/font/sans' +import React from 'react' + +import { AdminBar } from '@/components/AdminBar' +import { Footer } from '@/Footer/Component' +import { Header } from '@/Header/Component' +import { Providers } from '@/providers' +import { InitTheme } from '@/providers/Theme/InitTheme' +import { mergeOpenGraph } from '@/utilities/mergeOpenGraph' +import { draftMode } from 'next/headers' + +import './globals.css' +import { getServerSideURL } from '@/utilities/getURL' + +// Cloudflare Workers don't support static generation at build time +export const dynamic = 'force-dynamic' + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const { isEnabled } = await draftMode() + + return ( + + + + + + + + + + +
+ {children} +