├── .editorconfig ├── .env.example ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── assertions.yml │ └── codeql-analysis.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app ├── [slug] │ └── page.tsx ├── api │ └── revalidate │ │ └── route.ts ├── blog │ ├── [slug] │ │ └── page.tsx │ ├── category │ │ └── [slug] │ │ │ └── page.tsx │ └── tag │ │ └── [slug] │ │ └── page.tsx ├── books │ └── [slug] │ │ └── page.tsx ├── favicon.ico ├── feed.xml │ └── route.ts ├── globals.css ├── layout.tsx ├── not-found.tsx ├── page.tsx ├── preview │ └── [slug] │ │ └── page.tsx ├── robots.ts ├── search │ └── page.tsx └── sitemap.ts ├── components ├── CommentForm.tsx ├── Footer.tsx ├── Header.tsx └── SearchForm.tsx ├── eslint.config.mjs ├── lefthook.yml ├── lib ├── config.ts ├── functions.ts ├── mutations │ └── createComment.ts ├── queries │ ├── getAllBooks.ts │ ├── getAllPages.ts │ ├── getAllPosts.ts │ ├── getBookBySlug.ts │ ├── getCategoryBySlug.ts │ ├── getMenuBySlug.ts │ ├── getPageBySlug.ts │ ├── getPostBySlug.ts │ ├── getPreview.ts │ └── getTagBySlug.ts └── types.d.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | end_of_line = lf 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # WordPress GraphQL URL. No trailing slash. 2 | NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL="https://blog.nextjswp.com/graphql" 3 | 4 | # WordPress REST API URL. No trailing slash. 5 | NEXT_PUBLIC_WORDPRESS_REST_API_URL="https://blog.nextjswp.com/wp-json/wp/v2" 6 | 7 | # Optional. JWT auth refresh token. 8 | #NEXTJS_AUTH_REFRESH_TOKEN="" 9 | 10 | # Preview Secret. Must match the constant in wp-config.php. 11 | NEXTJS_PREVIEW_SECRET="preview" 12 | 13 | # Revalidation Secret. Must match the constant in wp-config.php. 14 | NEXTJS_REVALIDATION_SECRET="revalidate" 15 | 16 | # Allow self-signed SSL certificates when working with local development environments. 17 | #NODE_TLS_REJECT_UNAUTHORIZED=0 18 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code Owners 2 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | * @gregrickaby -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: gregrickaby 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | ### Description 4 | 5 | What does your Pull Request do? Give some context... 6 | 7 | ### Screenshot 8 | 9 | If possible, add some screenshots of your feature. 10 | 11 | ### Verification 12 | 13 | How will a code reviewer test this? 14 | 15 | 1. 16 | 2. 17 | 3. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: './workflows' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'monday' 8 | 9 | - package-ecosystem: 'npm' 10 | directory: '/' 11 | schedule: 12 | interval: 'weekly' 13 | day: 'monday' 14 | -------------------------------------------------------------------------------- /.github/workflows/assertions.yml: -------------------------------------------------------------------------------- 1 | name: Assertions 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | workflow_dispatch: 8 | 9 | jobs: 10 | assertions: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 'lts/*' 21 | cache: 'npm' 22 | 23 | - name: Copy .env 24 | run: cp .env.example .env 25 | 26 | - name: Install Dependencies 27 | run: npm ci --ignore-scripts 28 | 29 | - name: Lint 30 | run: npm run lint 31 | 32 | - name: Test Build 33 | run: npm run build 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['main'] 20 | schedule: 21 | - cron: '28 23 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v2 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | with: 73 | category: '/language:${{matrix.language}}' 74 | -------------------------------------------------------------------------------- /.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 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # local wordpress 38 | wordpress/ 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | singleQuote: true, 5 | bracketSpacing: false, 6 | semi: false, 7 | trailingComma: 'none', 8 | plugins: ['prettier-plugin-tailwindcss'] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "GraphQL.vscode-graphql-syntax", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnPaste": true, 7 | "editor.formatOnSave": true, 8 | "eslint.format.enable": false, 9 | "eslint.run": "onSave", 10 | "javascript.suggest.autoImports": true, 11 | "javascript.updateImportsOnFileMove.enabled": "always", 12 | "typescript.suggest.autoImports": true, 13 | "typescript.updateImportsOnFileMove.enabled": "always", 14 | "[typescript]": { 15 | "editor.codeActionsOnSave": { 16 | "source.organizeImports": "explicit" 17 | } 18 | }, 19 | "[typescriptreact]": { 20 | "editor.codeActionsOnSave": { 21 | "source.organizeImports": "explicit" 22 | } 23 | }, 24 | "[typescript][typescriptreact]": { 25 | "editor.codeActionsOnSave": { 26 | "source.organizeImports": "explicit" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Here are the ways to get involved with this project: 4 | 5 | - [Submitting issues](#submitting-issues) 6 | - [Contributing code](#contributing-code) 7 | - [Development Setup](#development-setup) 8 | - [Git Workflow](#git-workflow) 9 | - [Vercel CLI](#vercel-cli) 10 | - [Legal Stuff](#legal-stuff) 11 | 12 | ## Submitting issues 13 | 14 | Before submitting your issue, make sure it has not been mentioned earlier. You can search through the [existing issues](https://github.com/gregrickaby/nextjs-wordpress/issues) or [Github Discussions](https://github.com/gregrickaby/nextjs-wordpress/discussions). 15 | 16 | --- 17 | 18 | ## Contributing code 19 | 20 | Found a bug you can fix? Fantastic! Patches are always welcome. 21 | 22 | --- 23 | 24 | ### Git Workflow 25 | 26 | 1. Fork the repo and create a feature/patch branch off `main` 27 | 2. Work locally adhering to coding standards 28 | 3. Run `npm run lint` 29 | 4. Make sure the app builds locally with `npm run build && npm run start` 30 | 5. Push your code, open a PR, and fill out the PR template 31 | 6. After peer review, the PR will be merged back into `main` 32 | 7. Repeat ♻️ 33 | 34 | > Your PR must pass automated assertions, deploy to Vercel successfully, and pass a peer review before it can be merged. 35 | 36 | --- 37 | 38 | ### Vercel CLI 39 | 40 | I've found that running `vercel` locally is a great way to verify Edge Functions and Middleware are working as expected. 41 | 42 | To install the [Vercel CLI](https://vercel.com/docs/cli), run: 43 | 44 | ```bash 45 | npm i -g vercel 46 | ``` 47 | 48 | Start a Vercel development server locally: 49 | 50 | ```bash 51 | vercel dev 52 | ``` 53 | 54 | --- 55 | 56 | ## Legal Stuff 57 | 58 | This repo is maintained by [Greg Rickaby](https://gregrickaby.com/). By contributing code you grant its use under the [MIT](https://github.com/gregrickaby/nextjs-wordpress/blob/main/LICENSE). 59 | 60 | --- 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Greg Rickaby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js + WordPress 2 | 3 | It's headless WordPress! 💀 - 4 | 5 | This is a bare-bones Next.js app, which fetches data from WordPress via WPGraphQL and styles it with TailwindCSS. 6 | 7 | Please consider it a starting point for your next headless WordPress project. 8 | 9 | --- 10 | 11 | ## Supported Features 12 | 13 | - Category and Tag Archives 14 | - Comments 15 | - Custom Fields 16 | - Custom Post Types 17 | - On-demand Revalidation 18 | - Post/Page Previews 19 | - RSS Feed 20 | - Robots.txt 21 | - Search 22 | - Sitemap.xml 23 | - Static Site Generation (SSG) 24 | - TypeScript, ESLint, and Prettier 25 | - WordPress Menus 26 | - Yoast SEO 27 | 28 | Plus it's really, really fast! 🚀 29 | 30 | ![screenshot](https://dl.dropbox.com/s/xh6uq9mblx8rqm1/Screenshot%202023-10-21%20at%2009.58.44.png?dl=0) 31 | 32 | --- 33 | 34 | ## Setup 35 | 36 | ### 1. Clone the repo 37 | 38 | ```bash 39 | git clone git@github.com:gregrickaby/nextjs-wordpress.git 40 | ``` 41 | 42 | ### 2. Install dependencies 43 | 44 | ```bash 45 | npm i 46 | ``` 47 | 48 | ### 3. Create a `.env.local` file 49 | 50 | ```bash 51 | cp .env.example .env.local 52 | ``` 53 | 54 | Customize the URLs in `.env.local` to match your WordPress setup: 55 | 56 | ```txt 57 | # WordPress GraphQL API URL. No trailing slash. 58 | NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL="https://blog.nextjswp.com/graphql" 59 | 60 | # WordPress REST API URL. No trailing slash. 61 | NEXT_PUBLIC_WORDPRESS_REST_API_URL="https://blog.nextjswp.com/wp-json/wp/v2" 62 | 63 | # Optional. JWT auth refresh token. 64 | #NEXTJS_AUTH_REFRESH_TOKEN="" 65 | 66 | # Preview Secret. Must match the constant in wp-config.php. 67 | NEXTJS_PREVIEW_SECRET="preview" 68 | 69 | # Revalidation Secret. Must match the constant in wp-config.php. 70 | NEXTJS_REVALIDATION_SECRET="revalidate" 71 | ``` 72 | 73 | ### 4. Configure `next.config.js` 74 | 75 | Update the URL in `next.config.js` to match your WordPress site: 76 | 77 | ```ts 78 | const nextConfig = { 79 | images: { 80 | remotePatterns: [ 81 | { 82 | protocol: 'https', 83 | hostname: '*.nextjswp.**' // <-- Change this to your WordPress site 84 | } 85 | ] 86 | } 87 | } 88 | ``` 89 | 90 | ### 5. Configure `/lib/config.ts` 91 | 92 | Open `/lib/config.ts` and update the content to match your WordPress site: 93 | 94 | ```ts 95 | const config = { 96 | siteName: 'Next.js WordPress', 97 | siteDescription: "It's headless WordPress!", 98 | siteUrl: 'https://nextjswp.com', 99 | revalidation: 3600 100 | } 101 | ``` 102 | 103 | ### 6. Configure WordPress 104 | 105 | #### Plugins 106 | 107 | You'll need either a local or public WordPress site with the following plugins: 108 | 109 | - [Advanced Custom Fields](https://wordpress.org/plugins/advanced-custom-fields/) (free version is fine) 110 | - [Next.js WordPress Plugin](https://github.com/gregrickaby/nextjs-wordpress-plugin) 111 | - [Next.js WordPress Theme](https://github.com/gregrickaby/nextjs-wordpress-theme) 112 | - [WPGraphQL Yoast SEO](https://wordpress.org/plugins/add-wpgraphql-seo/) 113 | - [WPGraphQL for Advanced Custom Fields](https://wordpress.org/plugins/wpgraphql-acf/) 114 | - [WPGraphQL JWT Authentication](https://github.com/wp-graphql/wp-graphql-jwt-authentication) (optional) 115 | - [WPGraphQL](https://www.wpgraphql.com/) 116 | - [Yoast SEO](https://wordpress.org/plugins/wordpress-seo/) 117 | 118 | #### WP-Config 119 | 120 | After installing all the plugins mentioned above, you'll need to add some constants to your `wp-config.php` file: 121 | 122 | ```php 123 | // The URL of your Next.js frontend. Include the trailing slash. 124 | define( 'NEXTJS_FRONTEND_URL', 'https://nextjswp.com/' ); 125 | 126 | // Optional. JWT auth refresh token. 127 | //define( 'GRAPHQL_JWT_AUTH_SECRET_KEY', '' ); 128 | 129 | // Any random string. This must match the .env variable in the Next.js frontend. 130 | define( 'NEXTJS_PREVIEW_SECRET', 'preview' ); 131 | 132 | // Any random string. This must match the .env variable in the Next.js frontend. 133 | define( 'NEXTJS_REVALIDATION_SECRET', 'revalidate' ); 134 | ``` 135 | 136 | #### Permalinks 137 | 138 | Finally, set your permalink structure to `/blog/%postname%/` in **Settings -> Permalinks**. 139 | 140 | ### 7. Optional. Authentication for Previews 141 | 142 | In order to query draft posts for Previews, you'll need to authenticate with WordPress. The following is a one-time step: 143 | 144 | - Install and activate the [WPGraphQL JWT Authentication](https://github.com/wp-graphql/wp-graphql-jwt-authentication) plugin 145 | - Generate any random string. I recommend using the [WordPress salt generator](https://api.wordpress.org/secret-key/1.1/salt/) 146 | - Copy the string 147 | - Open your `wp-config.php` file, and paste the string into the `GRAPHQL_JWT_AUTH_SECRET_KEY` constant 148 | 149 | ```php 150 | // Optional. JWT auth refresh token. 151 | define( 'GRAPHQL_JWT_AUTH_SECRET_KEY', 'the-random-string-generated-by-wp-salt' ); 152 | ``` 153 | 154 | - Go to **GraphQL -> GraphiQL IDE** in your WordPress admin 155 | - Copy the following and paste into GraphiQL IDE (replace `your_username` and `your_password` with your WordPress credentials) 156 | 157 | ```graphql 158 | mutation Login { 159 | login( 160 | input: { 161 | clientMutationId: "uniqueId" 162 | password: "your_password" 163 | username: "your_username" 164 | } 165 | ) { 166 | refreshToken 167 | } 168 | } 169 | ``` 170 | 171 | - Click the **Play** button in GraphiQL to run the mutation 172 | - Copy the `refreshToken` returned by the mutation 173 | - Open the Next.js `.env.local` file, and paste the `refreshToken` into the `NEXTJS_AUTH_REFRESH_TOKEN` variable 174 | 175 | ```txt 176 | # Optional. JWT auth refresh token. 177 | NEXTJS_AUTH_REFRESH_TOKEN="refresh-token-generated-by-grapqh-query" 178 | ``` 179 | 180 | You should now be able to preview draft posts in your Next.js app by clicking the **Preview** button in your WordPress admin. 181 | 182 | ### 8. Start the dev server 183 | 184 | ```bash 185 | npm run dev 186 | ``` 187 | 188 | Once the dev server has started, you can view the front-end: 189 | 190 | --- 191 | 192 | ## Querying WordPress data with GraphQL 193 | 194 | GraphQL is efficient because we can query multiple endpoints in a single request. If we were to use the WordPress REST-API, we would need to make multiple round trips to each respective endpoint. 195 | 196 | We can build our queries in GraphiQL (or your favorite REST client) and let `JSON.stringify()` format it. Because this is all standard JavaScript, we can even pass variables to our queries-- no need for a 3rd party package! 197 | 198 | Here is a query to fetch a single post (based on the slug), the featured image, author meta, categories, tags, SEO, and post comments: 199 | 200 | ```ts 201 | import {Post} from '@/lib/types' 202 | 203 | /** 204 | * Fetch a single post by slug. 205 | */ 206 | export async function getPostBySlug(slug: string) { 207 | // Define our query. 208 | const query = ` 209 | query GetPost($slug: ID!) { 210 | post(id: $slug, idType: SLUG) { 211 | databaseId 212 | content(format: RENDERED) 213 | title(format: RENDERED) 214 | featuredImage { 215 | node { 216 | altText 217 | mediaDetails { 218 | sizes(include: MEDIUM) { 219 | height 220 | width 221 | sourceUrl 222 | } 223 | } 224 | } 225 | } 226 | author { 227 | node { 228 | avatar { 229 | url 230 | } 231 | name 232 | } 233 | } 234 | date 235 | tags { 236 | nodes { 237 | databaseId 238 | name 239 | } 240 | } 241 | categories { 242 | nodes { 243 | databaseId 244 | name 245 | } 246 | } 247 | seo { 248 | metaDesc 249 | title 250 | } 251 | comments(first: 30, where: {order: ASC}) { 252 | nodes { 253 | content(format: RENDERED) 254 | databaseId 255 | date 256 | status 257 | author { 258 | node { 259 | avatar { 260 | url 261 | } 262 | email 263 | name 264 | url 265 | } 266 | } 267 | } 268 | } 269 | } 270 | } 271 | ` 272 | 273 | // Define our variables. 274 | const variables = { 275 | slug: slug 276 | } 277 | 278 | // Fetch the data using a reusable fetch function. Next.js 279 | // automatically memoizes and caches these requests. 280 | const response = await fetchGraphQL(query, variables) 281 | 282 | // Return the post. 283 | return response.data.post as Post 284 | } 285 | ``` 286 | 287 | This repo does not use a 3rd party GraphQL package, because Next.js automatically memoizes the `fetch()` requests in our custom fetch function. This means that if we fetch the same data twice, Next.js will only make one request to WordPress. 288 | 289 | > If you prefer use a 3rd party GraphQL package, simply swap out the custom `fetchGraphQL()` function with the package of your choosing. 290 | 291 | --- 292 | 293 | ### Going To Production 294 | 295 | Remember to add all the environment variables from `.env.local` to your production environment on [Vercel](https://vercel.com) or [Netlify](https://netlify.com). 296 | 297 | --- 298 | 299 | ### Other 300 | 301 | #### RSS Feed, Sitemap, Robots.txt 302 | 303 | - 304 | - 305 | - 306 | 307 | #### Previews 308 | 309 | - 310 | 311 | --- 312 | 313 | ## Contributing 314 | 315 | This is a hobby project and my time is limited, so your contributions are welcome! Please see the [contributing guidelines](./CONTRIBUTING.md) to get started. 316 | 317 | --- 318 | -------------------------------------------------------------------------------- /app/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import getAllBooks from '@/lib/queries/getAllBooks' 2 | import getAllPosts from '@/lib/queries/getAllPosts' 3 | import getPageBySlug from '@/lib/queries/getPageBySlug' 4 | import type {DynamicPageProps} from '@/lib/types' 5 | import {Page, Post} from '@/lib/types' 6 | import Image from 'next/image' 7 | import Link from 'next/link' 8 | import {notFound} from 'next/navigation' 9 | 10 | /** 11 | * Fetches data from WordPress. 12 | */ 13 | async function fetchData(slug: string) { 14 | // If the slug is 'blog', fetch all posts. 15 | if (slug === 'blog') { 16 | return {posts: await getAllPosts(), context: 'blog'} 17 | } 18 | 19 | // If the slug is 'books', fetch all books. 20 | if (slug === 'books') { 21 | return {posts: await getAllBooks(), context: 'books'} 22 | } 23 | 24 | // Otherwise, this could be a page. 25 | const page = await getPageBySlug(slug) 26 | 27 | // If page data exists, return it. 28 | if (page) { 29 | return {post: page} 30 | } 31 | 32 | // Otherwise, return an error. 33 | return {error: 'No data found'} 34 | } 35 | 36 | /** 37 | * Render a single page. 38 | */ 39 | function RenderPage({page}: {page: Page}) { 40 | return ( 41 |
42 |
43 |

44 |
45 |

46 |
47 | ) 48 | } 49 | 50 | /** 51 | * Render posts list. 52 | */ 53 | function RenderPostsList({posts, context}: {posts: Post[]; context: string}) { 54 | return ( 55 |
56 |

Latest {context}

57 |
58 | {posts.map((post: Post) => ( 59 |
60 | {post.featuredImage.node.altText} 67 | 68 |

69 | 70 |

71 | {post.commentCount} Comments 72 |

73 |
74 | 75 | View Post 76 | 77 |

78 | ))} 79 |
80 |
81 | ) 82 | } 83 | 84 | /** 85 | * Catch-all Archive Page route. 86 | */ 87 | export default async function Archive({params}: Readonly) { 88 | // Get the slug from the params. 89 | const {slug} = await params 90 | 91 | // Fetch data from WordPress. 92 | const data = await fetchData(slug) 93 | 94 | // If there's an error, return a 404 page. 95 | if (data.error) { 96 | notFound() 97 | } 98 | 99 | // If this is a single page, render the page. 100 | if (data.post) { 101 | return 102 | } 103 | 104 | // Otherwise, this must be an archive. Render the posts list. 105 | if (data.posts && data.posts.length > 0) { 106 | return 107 | } 108 | 109 | // Otherwise, return a 404 page. 110 | notFound() 111 | } 112 | -------------------------------------------------------------------------------- /app/api/revalidate/route.ts: -------------------------------------------------------------------------------- 1 | import {revalidatePath, revalidateTag} from 'next/cache' 2 | import {NextRequest} from 'next/server' 3 | 4 | /** 5 | * On-demand revalidation. 6 | * 7 | * ### Notes 8 | * Because Next.js has such aggressive caching, 9 | * we need to invalidate the following items: 10 | * 11 | * 1. The path/slug to the SSG page. 12 | * 2. The cached GraphQL query for the page. 13 | * 3. The cached GraphQL query for _all_ fetches. 14 | * 15 | * ### Important 16 | * This route _must_ be a GET request! 17 | * 18 | * ### Usage 19 | * 20 | * Send a GET request (with secret header) to: 21 | * `/api/revalidate?slug=foo-bar-baz` 22 | * 23 | * ### References 24 | * @see https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation 25 | * @see https://nextjs.org/docs/app/api-reference/functions/revalidateTag 26 | * @see https://nextjs.org/docs/app/api-reference/functions/revalidatePath 27 | */ 28 | export async function GET(request: NextRequest) { 29 | const secret = request.headers.get('x-vercel-revalidation-secret') 30 | const slug = request.nextUrl.searchParams.get('slug') 31 | 32 | // Validate the secret. 33 | if (secret !== process.env.NEXTJS_REVALIDATION_SECRET) { 34 | return new Response( 35 | JSON.stringify({revalidated: false, message: 'Invalid secret'}), 36 | { 37 | status: 401, 38 | headers: { 39 | 'Content-Type': 'application/json', 40 | 'X-Robots-Tag': 'noindex' 41 | } 42 | } 43 | ) 44 | } 45 | 46 | // Validate the post slug. 47 | if (!slug) { 48 | return new Response( 49 | JSON.stringify({revalidated: false, message: 'Invalid slug parameter.'}), 50 | { 51 | status: 400, 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | 'X-Robots-Tag': 'noindex' 55 | } 56 | } 57 | ) 58 | } 59 | 60 | try { 61 | // Revalidate the static page. 62 | revalidatePath(slug, 'page') 63 | 64 | // Revalidate the layout. 65 | revalidatePath('/', 'layout') 66 | 67 | // Revalidate everything. 68 | revalidatePath('/') 69 | 70 | // Revalidate the cached GraphQL queries. 71 | revalidateTag(slug) 72 | revalidateTag('graphql') // This tag is set in `lib/functions.ts`. 73 | 74 | return new Response( 75 | JSON.stringify({ 76 | revalidated: true, 77 | revalidatePath: slug, 78 | revalidateTags: [slug, 'graphql'], 79 | revalidationTime: Date.now() 80 | }), 81 | { 82 | headers: { 83 | 'Content-Type': 'application/json', 84 | 'X-Robots-Tag': 'noindex' 85 | } 86 | } 87 | ) 88 | } catch (error) { 89 | const errorMessage = 90 | error instanceof Error ? error.message : 'Unknown error.' 91 | 92 | return new Response( 93 | JSON.stringify({ 94 | revalidated: false, 95 | revalidatePath: slug, 96 | revalidateTag: 'graphql', 97 | revalidationTime: Date.now(), 98 | error: errorMessage 99 | }), 100 | { 101 | status: 500, 102 | headers: { 103 | 'Content-Type': 'application/json', 104 | 'X-Robots-Tag': 'noindex' 105 | } 106 | } 107 | ) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import CommentForm from '@/components/CommentForm' 2 | import getAllPosts from '@/lib/queries/getAllPosts' 3 | import getPostBySlug from '@/lib/queries/getPostBySlug' 4 | import type {DynamicPageProps} from '@/lib/types' 5 | import {Metadata} from 'next' 6 | import Image from 'next/image' 7 | import Link from 'next/link' 8 | import {notFound} from 'next/navigation' 9 | 10 | /** 11 | * Generate the static routes at build time. 12 | * 13 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params 14 | */ 15 | export async function generateStaticParams() { 16 | // Get all blog posts. 17 | const posts = await getAllPosts() 18 | 19 | // No posts? Bail... 20 | if (!posts) { 21 | return [] 22 | } 23 | 24 | // Return the slugs for each post. 25 | return posts.map((post: {slug: string}) => ({ 26 | slug: post.slug 27 | })) 28 | } 29 | 30 | /** 31 | * Generate the metadata for each static route at build time. 32 | * 33 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function 34 | */ 35 | export async function generateMetadata({ 36 | params 37 | }: DynamicPageProps): Promise { 38 | // Get the slug from the params. 39 | const {slug} = await params 40 | 41 | // Get the blog post. 42 | const post = await getPostBySlug(slug) 43 | 44 | // No post? Bail... 45 | if (!post) { 46 | return {} 47 | } 48 | 49 | return { 50 | title: post.seo.title, 51 | description: post.seo.metaDesc 52 | } 53 | } 54 | 55 | /** 56 | * The blog post route. 57 | * 58 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages 59 | */ 60 | export default async function Post({params}: Readonly) { 61 | // Get the slug from the params. 62 | const {slug} = await params 63 | 64 | // Fetch a single post from WordPress. 65 | const post = await getPostBySlug(slug) 66 | 67 | // No post? Bail... 68 | if (!post) { 69 | notFound() 70 | } 71 | 72 | return ( 73 |
74 |
75 |

76 |

77 | By {post.author.node.name} on 78 |

79 |

80 |
81 |
82 |
83 |

Categories

84 |
    85 | {post.categories.nodes.map((category) => ( 86 |
  • 87 | 88 | {category.name} 89 | 90 |
  • 91 | ))} 92 |
93 |
94 | 95 |
96 |

Tags

97 |
    98 | {post.tags.nodes.map((tag) => ( 99 |
  • 100 | {tag.name} 101 |
  • 102 | ))} 103 |
104 |
105 |
106 |
107 |

Comments

108 | {post.comments.nodes.map((comment) => ( 109 |
110 |
111 | {comment.author.node.name} 119 |
120 |

124 | 125 |

126 |
127 | 128 |
129 |
130 | ))} 131 |
132 | 133 |
134 | ) 135 | } 136 | -------------------------------------------------------------------------------- /app/blog/category/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import getCategoryBySlug from '@/lib/queries/getCategoryBySlug' 3 | import {DynamicPageProps} from '@/lib/types' 4 | import {Metadata} from 'next' 5 | import Image from 'next/image' 6 | import Link from 'next/link' 7 | import {notFound} from 'next/navigation' 8 | 9 | /** 10 | * Generate the metadata for each static route at build time. 11 | * 12 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function 13 | */ 14 | export async function generateMetadata({ 15 | params 16 | }: DynamicPageProps): Promise { 17 | // Get the slug from the params. 18 | const {slug} = await params 19 | 20 | return { 21 | title: `${slug} Archives - ${config.siteName}`, 22 | description: `The category archive for ${slug}` 23 | } 24 | } 25 | 26 | /** 27 | * The category archive route. 28 | * 29 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages 30 | */ 31 | export default async function CategoryArchive({ 32 | params 33 | }: Readonly) { 34 | // Get the slug from the params. 35 | const {slug} = await params 36 | 37 | // Fetch posts from WordPress. 38 | const posts = await getCategoryBySlug(slug) 39 | 40 | // No posts? Bail... 41 | if (!posts) { 42 | notFound() 43 | } 44 | 45 | return ( 46 |
47 |

Post Category: {slug}

48 |
49 | {posts.map((post) => ( 50 |
51 | {post.featuredImage.node.altText} 58 | 59 |

60 | 61 |

62 | {post.commentCount} Comments 63 |

64 |
65 | 66 | View Post 67 | 68 |

69 | ))} 70 |
71 |
72 | ) 73 | } 74 | -------------------------------------------------------------------------------- /app/blog/tag/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import getTagBySlug from '@/lib/queries/getTagBySlug' 3 | import type {DynamicPageProps} from '@/lib/types' 4 | import {Metadata} from 'next' 5 | import Image from 'next/image' 6 | import Link from 'next/link' 7 | import {notFound} from 'next/navigation' 8 | 9 | /** 10 | * Generate the metadata for each static route at build time. 11 | * 12 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function 13 | */ 14 | export async function generateMetadata({ 15 | params 16 | }: Readonly): Promise { 17 | // Get the slug from the params. 18 | const {slug} = await params 19 | 20 | return { 21 | title: `${slug} Archives - ${config.siteName}`, 22 | description: `The tag archive for ${slug}` 23 | } 24 | } 25 | 26 | /** 27 | * The tag archive route. 28 | * 29 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages 30 | */ 31 | export default async function TagArchive({params}: Readonly) { 32 | // Get the slug from the params. 33 | const {slug} = await params 34 | 35 | // Fetch posts from WordPress. 36 | const posts = await getTagBySlug(slug) 37 | 38 | // No posts? Bail... 39 | if (!posts) { 40 | notFound() 41 | } 42 | 43 | return ( 44 |
45 |

Post Tag: {slug}

46 |
47 | {posts.map((post) => ( 48 |
49 | {post.featuredImage.node.altText} 56 | 57 |

58 | 59 |

60 | {post.commentCount} Comments 61 |

62 |
63 | 64 | View Post 65 | 66 |

67 | ))} 68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /app/books/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import getAllBooks from '@/lib/queries/getAllBooks' 2 | import getBookBySlug from '@/lib/queries/getBookBySlug' 3 | import type {DynamicPageProps} from '@/lib/types' 4 | import {Metadata} from 'next' 5 | import Link from 'next/link' 6 | import {notFound} from 'next/navigation' 7 | 8 | /** 9 | * Generate the static routes at build time. 10 | * 11 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params 12 | */ 13 | export async function generateStaticParams() { 14 | // Get a list of all books. 15 | const books = await getAllBooks() 16 | 17 | // No books? Bail... 18 | if (!books) { 19 | return [] 20 | } 21 | 22 | // Return the slugs for each book. 23 | return books.map((book: {slug: string}) => ({ 24 | slug: book.slug 25 | })) 26 | } 27 | 28 | /** 29 | * Generate the metadata for each static route at build time. 30 | * 31 | * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function 32 | */ 33 | export async function generateMetadata({ 34 | params 35 | }: Readonly): Promise { 36 | // Get the slug from the params. 37 | const {slug} = await params 38 | 39 | // Get the page. 40 | const book = await getBookBySlug(slug) 41 | 42 | // No post? Bail... 43 | if (!book) { 44 | return {} 45 | } 46 | 47 | return { 48 | title: book.seo.title, 49 | description: book.seo.metaDesc 50 | } 51 | } 52 | 53 | /** 54 | * A single book route. 55 | * 56 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages 57 | */ 58 | export default async function Book({params}: Readonly) { 59 | // Get the slug from the params. 60 | const {slug} = await params 61 | 62 | // Fetch a single book from WordPress. 63 | const book = await getBookBySlug(slug) 64 | 65 | // No book? Bail... 66 | if (!book) { 67 | notFound() 68 | } 69 | 70 | return ( 71 |
72 |
73 |

74 |
75 | 76 | View on Amazon 77 | 78 |

79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregrickaby/nextjs-wordpress/6d33c3b4a1f66f6e36a5915c237b6357ae62d761/app/favicon.ico -------------------------------------------------------------------------------- /app/feed.xml/route.ts: -------------------------------------------------------------------------------- 1 | import config from '@/lib/config' 2 | import getAllPosts from '@/lib/queries/getAllPosts' 3 | import escape from 'xml-escape' 4 | 5 | /** 6 | * Route handler for generating RSS feed. 7 | * 8 | * @see https://nextjs.org/docs/app/api-reference/file-conventions/route 9 | */ 10 | export async function GET() { 11 | // Fetch all posts. 12 | const allPosts = await getAllPosts() 13 | 14 | // If no posts, return response. 15 | if (!allPosts) { 16 | return new Response('No posts found.', { 17 | headers: { 18 | 'Content-Type': 'application/xml; charset=utf-8' 19 | } 20 | }) 21 | } 22 | 23 | // Start of RSS feed. 24 | let rss = ` 25 | 26 | 27 | ${config.siteName} 28 | ${config.siteDescription} 29 | ${config.siteUrl} 30 | RSS for Node and Next.js 31 | ${new Date().toUTCString()} 32 | 60` 33 | 34 | // Add posts to RSS feed. 35 | allPosts.forEach((post) => { 36 | rss += ` 37 | 38 | ${escape(post.title)} 39 | ${escape(post.excerpt)} 40 | ${config.siteUrl}/blog/${post.slug} 41 | ${config.siteUrl}/blog/${post.slug} 42 | ${new Date(post.date).toUTCString()} 43 | ` 44 | }) 45 | 46 | // Close channel and rss tag. 47 | rss += ` 48 | 49 | ` 50 | 51 | // Return response. 52 | return new Response(rss, { 53 | headers: { 54 | 'Content-Type': 'application/xml; charset=utf-8' 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | /* Example Styles. Delete to start fresh! */ 4 | body { 5 | @apply container mx-auto grid grid-cols-1 gap-4 p-4; 6 | @apply text-xl; 7 | } 8 | 9 | h1 { 10 | @apply text-4xl font-bold; 11 | } 12 | 13 | h2 { 14 | @apply text-2xl font-bold; 15 | } 16 | 17 | h3 { 18 | @apply text-xl font-bold; 19 | } 20 | 21 | a { 22 | @apply text-blue-700 underline; 23 | } 24 | 25 | p { 26 | @apply my-4; 27 | } 28 | 29 | input { 30 | @apply rounded border border-gray-300 p-2; 31 | } 32 | 33 | ul { 34 | @apply list-inside list-disc; 35 | } 36 | 37 | ol { 38 | @apply list-inside list-decimal; 39 | } 40 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import Footer from '@/components/Footer' 2 | import Header from '@/components/Header' 3 | import config from '@/lib/config' 4 | import type {Metadata} from 'next' 5 | import './globals.css' 6 | 7 | /** 8 | * Setup metadata. 9 | * 10 | * @see https://nextjs.org/docs/app/building-your-application/optimizing/metadata 11 | */ 12 | export const metadata: Metadata = { 13 | metadataBase: new URL(config.siteUrl), 14 | title: `${config.siteName} - ${config.siteDescription}`, 15 | description: config.siteDescription 16 | } 17 | 18 | /** 19 | * Root layout component. 20 | * 21 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#root-layout-required 22 | */ 23 | export default function RootLayout({ 24 | children 25 | }: Readonly<{children: React.ReactNode}>) { 26 | return ( 27 | 28 | 29 |
30 | {children} 31 |