Tìm hiểu SEO feed.xml, sitemap.ts, robots.ts nextjs-wordpress

Database cấu hình thực hành SEO

https://drive.google.com/file/d/16upcMf9wTCihcL5IWtmxzL-HqGO_VnYt/view?usp=sharing

wp-config.php

<?php
/**
 * The base configuration for WordPress
 *
 * The wp-config.php creation script uses this file during the installation.
 * You don't have to use the website, you can copy this file to "wp-config.php"
 * and fill in the values.
 *
 * This file contains the following configurations:
 *
 * * Database settings
 * * Secret keys
 * * Database table prefix
 * * ABSPATH
 *
 * @link https://developer.wordpress.org/advanced-administration/wordpress/wp-config/
 *
 * @package WordPress
 */
// ** Database settings - You can get this info from your web host ** //
/** The name of the database for WordPress */
define( 'DB_NAME', 'wpclidemo' );
/** Database username */
define( 'DB_USER', 'root' );
/** Database password */
define( 'DB_PASSWORD', '' );
/** Database hostname */
define( 'DB_HOST', 'localhost' );
/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8mb4' );
/** The database collate type. Don't change this if in doubt. */
define( 'DB_COLLATE', '' );
// Any random string. This must match the .env variable in the Next.js frontend.
define( 'NEXTJS_PREVIEW_SECRET', 'preview' );
// Any random string. This must match the .env variable in the Next.js frontend.
define( 'NEXTJS_REVALIDATION_SECRET', 'revalidate' );
define('AUTH_KEY',         '*N-t[-#n-3#-qv*c8K4iPf^^w*#WCu6$-(qD3cyatx^% A^QnG71Y0? O9ex}j]/');
define('SECURE_AUTH_KEY',  'F?&#3s`7T*.m0OwP7az$Iw}!iS>D# Z5SQr+QY)DLH)klS[|UX_EIn}vs%IT_+FG');
define('LOGGED_IN_KEY',    'brdC.{e#r9x&_U>|(D02U@)]KsUQ#c3 SqIR|Y^/5A!W|9%I?;}%rLFRrB2p,vG=');
define('NONCE_KEY',        '^e?>O4!YOCU|BXw`+m8NePd|QvTX~n,f8W;bUKpCU`PI|}+V;l~<j~>I>PZj7f,G');
define('AUTH_SALT',        '1x;V[+Qd}DyF2ZPB}g{QX!>NF)lG8$3MrFi],YVIJ@C`OD3$&ZG9l-kGE3o~hr?|');
define('SECURE_AUTH_SALT', 'qxrb)txU(M;Y$A6?=~bl_= rJ,7EAx`_<+|WnO_<Pz~B:actx!B5k!F#@ bKQ4q7');
define('LOGGED_IN_SALT',   '2vrPIByb8hZd7Z%4s~V8^=ZcyZ3TDi&I(c-X^0LxC[R|4A@=<yo|.#h=9;W!=]Cw');
define('NONCE_SALT',       'b?`&z:h)+3&w|o:<]gaS8zG^$P9f*l4FhE.YixsaE,U>EqQfn-v|Mo*465AE3+U|');
#define('GRAPHQL_JWT_AUTH_SECRET_KEY', 'wQ2gp%lGB(T0~!eV?FJg3M+tAy-R0YLF2rH_ Lou>k|7iFfGuH+0#oPTLXiG@8r-');
define('GRAPHQL_JWT_AUTH_SECRET_KEY', 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dwY2xpZGVtby5kZXYiLCJpYXQiOjE3Mzk5OTU4MjgsIm5iZiI6MTczOTk5NTgyOCwiZXhwIjoxNzcxNTMxODI4LCJkYXRhIjp7InVzZXIiOnsiaWQiOiIxIiwidXNlcl9zZWNyZXQiOiJncmFwaHFsX2p3dF82N2I0ZTM1ZDhiODQyIn19fQ.FwrXtFD2flxFNEwDveyhByXJc7s5nL875QAz55P3TbM');
/**#@+
 * Authentication unique keys and salts.
 *
 * Change these to different unique phrases! You can generate these using
 * the {@link https://api.wordpress.org/secret-key/1.1/salt/ WordPress.org secret-key service}.
 *
 * You can change these at any point in time to invalidate all existing cookies.
 * This will force all users to have to log in again.
 *
 * @since 2.6.0
 */
/**#@-*/
/**
 * WordPress database table prefix.
 *
 * You can have multiple installations in one database if you give each
 * a unique prefix. Only numbers, letters, and underscores please!
 *
 * At the installation time, database tables are created with the specified prefix.
 * Changing this value after WordPress is installed will make your site think
 * it has not been installed.
 *
 * @link https://developer.wordpress.org/advanced-administration/wordpress/wp-config/#table-prefix
 */
$table_prefix = 'wp_';
/**
 * For developers: WordPress debugging mode.
 *
 * Change this to true to enable the display of notices during development.
 * It is strongly recommended that plugin and theme developers use WP_DEBUG
 * in their development environments.
 *
 * For information on other constants that can be used for debugging,
 * visit the documentation.
 *
 * @link https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/
 */
define( 'WP_DEBUG', false );
define('NEXTJS_FRONTEND_URL', 'https://wpclidemo.dev/');
// define('ALLOW_UNFILTERED_UPLOADS', true);
define('WP_DEBUG_LOG', 'C:/xampp82/htdocs/wpclidemo/index.php/wp-errors.log');
/* Add any custom values between this line and the "stop editing" line. */
/* That's all, stop editing! Happy publishing. */
/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
	define( 'ABSPATH', __DIR__ . '/' );
}
/** Sets up WordPress vars and included files. */
require_once ABSPATH . 'wp-settings.php';

.env

# WordPress GraphQL URL. No trailing slash.
NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL="https://wpclidemo.dev/graphql"
# WordPress REST API URL. No trailing slash.
NEXT_PUBLIC_WORDPRESS_REST_API_URL="https://wpclidemo.dev/wp-json/wp/v2"
# Optional. JWT auth refresh token.
#NEXTJS_AUTH_REFRESH_TOKEN=""
# Preview Secret. Must match the constant in wp-config.php.
NEXTJS_PREVIEW_SECRET="preview"
# Revalidation Secret. Must match the constant in wp-config.php.
NEXTJS_REVALIDATION_SECRET="revalidate"
# Allow self-signed SSL certificates when working with local development environments.
NODE_TLS_REJECT_UNAUTHORIZED=0
NEXTJS_AUTH_REFRESH_TOKEN="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dwY2xpZGVtby5kZXYiLCJpYXQiOjE3Mzk5OTU4MjgsIm5iZiI6MTczOTk5NTgyOCwiZXhwIjoxNzcxNTMxODI4LCJkYXRhIjp7InVzZXIiOnsiaWQiOiIxIiwidXNlcl9zZWNyZXQiOiJncmFwaHFsX2p3dF82N2I0ZTM1ZDhiODQyIn19fQ.FwrXtFD2flxFNEwDveyhByXJc7s5nL875QAz55P3TbM"

[Home Page] Function to execute a GraphQL query

next.config.ts

/** @type {import('next').NextConfig} */
const allowedImageWordPressDomain = new URL(process.env.NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL).hostname;
const nextConfig = {
  reactStrictMode: true,
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: allowedImageWordPressDomain,
      },
      {
        protocol: "https",
        hostname: 'secure.gravatar.com',
      },
    ],
  }
};
export default nextConfig;

lib\config.ts

const config = {
  siteName: 'Next.js WordPress 15',
  siteDescription: "It's headless WordPress!",
  siteUrl: 'https://wpclidemo.dev',
  revalidate: 3600 // 1 hour
}
export default config

lib\types.d.ts

export interface DynamicPageProps {
  params: Promise<{
    slug: string
  }>
}
export interface SearchResults {
  id: number
  title: string
  url: string
  type: string
  subtype: string
}
export interface Children {
  children: React.ReactNode
}
export interface GraphQLResponse<T = any> {
  data?: T
  errors?: Array<{message: string}>
}
export interface Menu {
  menuItems: {
    edges: [
      {
        node: {
          uri: string
          label: string
          databaseId: string
        }
      }
    ]
  }
}
export interface FeaturedImage {
  node: {
    altText: string
    sourceUrl: string
    mediaDetails: {
      height: number
      width: number
    }
  }
}
export interface Menu {
  menuItems: {
    edges: [
      {
        node: {
          uri: string
          label: string
          databaseId: string
        }
      }
    ]
  }
}
export interface Page {
  author: {
    node: {
      avatar: {
        url: string
      }
      name: string
    }
  }
  databaseId: string
  date: string
  modified: string
  slug: string
  title: string
  excerpt: string
  content: string
  featuredImage: FeaturedImage
  seo: {
    metaDesc: string
    title: string
  }
}
export interface Post {
  author: {
    node: {
      name: string
      avatar: {
        url: string
      }
    }
  }
  databaseId: string
  date: string
  modified: string
  modified: string
  slug: string
  title: string
  excerpt: string
  content: string
  commentCount: number
  categories: {
    nodes: [
      {
        databaseId: string
        name: string
      }
    ]
  }
  tags: {
    nodes: [
      {
        databaseId: string
        name: string
      }
    ]
  }
  featuredImage: FeaturedImage
  seo: {
    metaDesc: string
    title: string
  }
  comments: {
    nodes: [
      {
        databaseId: string
        content: string
        date: string
        status: string
        author: {
          node: {
            avatar: {
              url: string
            }
            email: string
            name: string
            url: string
          }
        }
      }
    ]
  }
}
export interface Book {
  bookFields: {
    affiliateUrl: string
    isbn: string
  }
  databaseId: string
  date: string
  modified: string
  slug: string
  title: string
  excerpt: string
  content: string
  featuredImage: FeaturedImage
  seo: {
    metaDesc: string
    title: string
  }
}
export interface AllPages {
  pages: {
    nodes: Page[]
  }
}
export interface AllPosts {
  posts: {
    nodes: Post[]
  }
}

lib\functions.ts

import config from '@/lib/config'
import { GraphQLResponse, SearchResults } from '@/lib/types'
/**
 * Function to execute a GraphQL query.
 */
export async function fetchGraphQL<T = any>( query: string, variables?: { [key: string]: any }, preview = false ): Promise<GraphQLResponse<T>> {
  try {
    // Validate the WordPress GraphQL URL.
    const graphqlUrl = process.env.NEXT_PUBLIC_WORDPRESS_GRAPHQL_URL
    if (!graphqlUrl) {
      throw new Error('Missing WordPress GraphQL URL environment variable!')
    }
    // Get the refresh token.
    const refreshToken = process.env.NEXTJS_AUTH_REFRESH_TOKEN
    // Prepare headers.
    const headers: { [key: string]: string } = {
      'Content-Type': 'application/json'
    }
    // If preview mode is enabled and we have a token.
    if (preview && refreshToken) {
      // Add refresh token to fetch headers.
      headers['Authorization'] = `Bearer ${refreshToken}`
    }
    // Get the slug.
    const slug = variables?.slug || variables?.id || 'graphql'
    // Fetch data from external API.
    const response = await fetch(graphqlUrl, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        query,
        variables
      }),
      next: {
        tags: [slug],
        revalidate: config.revalidate
      }
    })
    // If the response status is not 200, throw an error.
    if (!response.ok) {
      console.error('Response Status:', response.status)
      throw new Error(response.statusText)
    }
    // Read the response as JSON.
    const data = await response.json()
    // Throw an error if there was a GraphQL error.
    if (data.errors) {
      console.error('GraphQL Errors:', data.errors)
      throw new Error('Error executing GraphQL query')
    }
    // Finally, return the data.
    return data
  } catch (error) {
    console.error(error)
    throw error
  }
}
/**
 * Search the WordPress REST API for posts matching the query.
 *
 * @see https://developer.wordpress.org/rest-api/reference/search-results/
 */
export async function searchQuery(query: string): Promise<SearchResults[]> {
  // Sanitize the search query.
  query = encodeURIComponent(query.trim())
  try {
    // If there is no URL, throw an error.
    if (!process.env.NEXT_PUBLIC_WORDPRESS_REST_API_URL) {
      throw new Error('Missing WordPress REST API URL environment variable!')
    }
    // Always fetch fresh search results.
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_WORDPRESS_REST_API_URL}/search?search=${query}&subtype=any&per_page=100`,
      {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json'
        },
        next: {
          tags: [`search-${query}`],
          revalidate: config.revalidate
        }
      }
    )
    // If the response status is not 200, throw an error.
    if (!response.ok) {
      console.error('Response Status:', response.status)
      throw new Error(response.statusText)
    }
    // Read the response as JSON.
    const data = await response.json()
    // Verify data has posts.
    if (!data || data.length === 0) {
      throw new Error('No posts found.')
    }
    // Return the data.
    return data as SearchResults[]
  } catch (error) {
    console.error('Error fetching data:', error)
    throw error
  }
}

lib\queries\getPageBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Page} from '@/lib/types'
/**
 * Fetch a page by slug.
 */
export default async function getPageBySlug(slug: string) {
  const query = `
    query GetPageBySlug($slug: ID = "URI") {
      page(idType: URI, id: $slug) {
        databaseId
        date
        modified
        content(format: RENDERED)
        title(format: RENDERED)
        featuredImage {
          node {
            altText
            sourceUrl
            mediaDetails {
                height
                width
            }
          }
        }
        author {
          node {
            name
            avatar {
              url
            }
          }
        }
        seo {
          metaDesc
          title
        }
      }
    }
  `
  const variables = {
    slug: slug
  }
  const response = await fetchGraphQL(query, variables)
  return response.data.page as Page
}

lib\queries\getAllPosts.ts

import {fetchGraphQL} from '@/lib/functions'
import {Post} from '@/lib/types'
/**
 * Fetch all blog posts.
 */
export default async function getAllPosts() {
  const query = `
    query GetAllPosts {
      posts(where: {status: PUBLISH}) {
        nodes {
          commentCount
          databaseId
          date
          modified
          title
          slug
          excerpt(format: RENDERED)
          featuredImage {
            node {
              altText
              sourceUrl
              mediaDetails {
                  height
                  width
              }
            }
          }
          seo {
            metaDesc
            title
          }
        }
      }
    }
  `
  const response = await fetchGraphQL(query)
  return response.data.posts.nodes as Post[]
}

components\Header.tsx

import config from '@/lib/config'
import getMenuBySlug from '@/lib/queries/getMenuBySlug'
import Link from 'next/link'
/**
 * Header component.
 */
export default async function Header() {
  // Get menu items from WordPress.
  const menu = await getMenuBySlug('header')
  return (
    <header>
      <div>
        <h1 className="mb-0">{config.siteName}</h1>
        <p>{config.siteDescription}</p>
      </div>
      <nav className="flex gap-4">
        {!!menu &&
          menu.menuItems.edges.map((item) => (
            <Link key={item.node.databaseId} href={item.node.uri}>
              {item.node.label}
            </Link>
          ))}
      </nav>
    </header>
  )
}

components\Footer.tsx

import config from '@/lib/config'
/**
 * Footer component.
 */
export default function Footer() {
  return (
    <footer className="border-t-2 p-8 text-center text-sm">
      &copy; 2022-{new Date().getFullYear()} - {config.siteName} by{' '}
      <a href="https://gregrickaby.com">Greg Rickaby</a> |{' '}
      <a href="https://github.com/gregrickaby/nextjs-wordpress">Github</a>
    </footer>
  )
}

components\CommentForm.tsx

'use client'
import {createComment} from '@/lib/mutations/createComment'
import {useState} from 'react'
/**
 * The comment form component.
 */
export default function CommentForm({postID}: Readonly<{postID: string}>) {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [website, setWebsite] = useState('')
  const [comment, setComment] = useState('')
  const [status, setStatus] = useState('')
  /**
   * Handle the comment form submission.
   */
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    // Create the comment and await the status.
    const status = await createComment({
      name,
      email,
      website,
      comment,
      postID
    })
    // If the comment was created successfully...
    if (status && status.success) {
      // Clear the form.
      setName('')
      setEmail('')
      setWebsite('')
      setComment('')
      // Set the status message.
      setStatus(
        `Thank you ${name}! Your comment has been submitted and is awaiting moderation.`
      )
    }
    // If there was an error...
    if (status && !status.success) {
      setStatus(`There was an error submitting your comment: ${status.message}`)
    }
  }
  return (
    <>
      <h3>Leave a Comment</h3>
      <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
        <div className="flex flex-col gap-2">
          <label htmlFor="name">Name*</label>
          <input
            id="name"
            onChange={(e) => setName(e.target.value)}
            required
            type="text"
            value={name}
          />
        </div>
        <div className="flex flex-col gap-2">
          <label htmlFor="email">Email*</label>
          <input
            id="email"
            onChange={(e) => setEmail(e.target.value)}
            pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$"
            required
            type="email"
            value={email}
          />
        </div>
        <div className="flex flex-col gap-2">
          <label htmlFor="website">Website*</label>
          <input
            id="website"
            onChange={(e) => setWebsite(e.target.value)}
            required
            type="url"
            value={website}
          />
        </div>
        <div className="flex flex-col gap-2">
          <label htmlFor="comment">Comment*</label>
          <textarea
            id="comment"
            onChange={(e) => setComment(e.target.value)}
            required
            value={comment}
          ></textarea>
        </div>
        <button type="submit">Submit</button>
        {status && <p>{status}</p>}
      </form>
    </>
  )
}

components\SearchForm.tsx

app\layout.tsx

import type { Metadata } from "next";
import Footer from '@/components/Footer'
import Header from '@/components/Header'
import config from '@/lib/config'
import "./globals.css";
export const metadata: Metadata = {
  metadataBase: new URL(config.siteUrl),
  title: `${config.siteName} - ${config.siteDescription}`,
  description: config.siteDescription
}
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`p-4 w-[1200px] m-auto`}>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

app\page.tsx

import getAllPosts from '@/lib/queries/getAllPosts';
import getPageBySlug from '@/lib/queries/getPageBySlug';
import { Post } from '@/lib/types';
import Link from 'next/link';
import { notFound } from 'next/navigation';
export default async function Home() {
  const homepage = await getPageBySlug('homepage');
  const posts = await getAllPosts();
  return (
    <div className="h-full flex flex-col gap-2">
      <article>
        <h1 className="font-bold text-5xl" dangerouslySetInnerHTML={{ __html: homepage.title }} />
        <div dangerouslySetInnerHTML={{ __html: homepage.content }} />
      </article>
      <h2 className="font-bold text-3xl">Latest Posts</h2>
      <aside className='grid grid-cols-3 gap-4 p-2 rounded-md'>
        {posts.map((post: Post) => (
          <article className='p-4 shadow-md' key={post.databaseId}>
            <Link href={`/${post.slug}`}>
              <h2 className="font-bold" dangerouslySetInnerHTML={{ __html: post.title }} />
            </Link>
            <p className="text-sm text-gray-500">{post.commentCount} Comments</p>
            <div className="mb-2" dangerouslySetInnerHTML={{ __html: post.excerpt }} />
            <Link className="bg-sky-500 hover:bg-sky-700 px-5 py-2 text-sm leading-5 rounded-full font-semibold text-white" href={`/${post.slug}`}>View Post</Link>
          </article>
        ))}
      </aside>
    </div>
  );
}

lib\queries\getMenuBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Menu} from '@/lib/types'
/**
 * Fetch a menu by slug.
 */
export default async function getMenuBySlug(slug: string) {
  const query = `
    query GetMenuBySlug($slug: ID = "URI") {
      menu(id: $slug, idType: SLUG) {
        menuItems {
          edges {
            node {
              uri
              label
              databaseId
            }
          }
        }
      }
    }
  `
  const variables = {
    slug: slug
  }
  const response = await fetchGraphQL(query, variables)
  return response.data.menu as Menu
}

Tạo một custom post type in graphql

wp-content\themes\astra-child\functions.php

<?php
add_filter('big_image_size_threshold', '__return_false');
add_filter('intermediate_image_sizes', 'remove_default_img_sizes', 10, 1);
function remove_default_img_sizes($sizes)
{
  $targets =
    [
      'thumbnail',
      'medium',
      'medium_large',
      'large',
      '1536x1536',
      '2048x2048',
      'woocommerce_thumbnail',
      'woocommerce_single',
      'woocommerce_gallery_thumbnail',
    ];
  foreach ($sizes as $size_index => $size) {
    if (in_array($size, $targets)) {
      unset($sizes[$size_index]);
    }
  }
  return $sizes;
}
if (! function_exists('astra_register_menu_locations')) {
  /**
   * Register menus
   *
   * @since 1.0.0
   */
  function astra_register_menu_locations()
  {
    /**
     * Primary Menus
     */
    register_nav_menus(
      array(
        'primary' => esc_html__('Primary Menu', 'astra'),
      )
    );
    /**
     * Primary Menus
     */
    register_nav_menus(
      array(
        'menu' => esc_html__('Menu', 'astra'),
      )
    );
    if (true === Astra_Builder_Helper::$is_header_footer_builder_active) {
      /**
       * Register the Secondary & Mobile menus.
       */
      register_nav_menus(
        array(
          'secondary_menu' => esc_html__('Secondary Menu', 'astra'),
          'mobile_menu'    => esc_html__('Off-Canvas Menu', 'astra'),
        )
      );
      $component_limit = defined('ASTRA_EXT_VER') ? Astra_Builder_Helper::$component_limit : Astra_Builder_Helper::$num_of_header_menu;
      for ($index = 3; $index <= $component_limit; $index++) {
        if (! is_customize_preview() && ! Astra_Builder_Helper::is_component_loaded('menu-' . $index)) {
          continue;
        }
        register_nav_menus(
          array(
            'menu_' . $index => esc_html__('Menu ', 'astra') . $index,
          )
        );
      }
      /**
       * Register the Account menus.
       */
      register_nav_menus(
        array(
          'loggedin_account_menu' => esc_html__('Logged In Account Menu', 'astra'),
        )
      );
    }
    /**
     * Footer Menus
     */
    register_nav_menus(
      array(
        'footer_menu' => esc_html__('Footer Menu', 'astra'),
      )
    );
  }
}
/**
 * Register a book post type.
 */
function wpdocs_codex_custom_init()
{
  $labels = array(
    'name'                  => _x('Books', 'Post type general name', 'textdomain'),
    'singular_name'         => _x('Book', 'Post type singular name', 'textdomain'),
    'menu_name'             => _x('Books', 'Admin Menu text', 'textdomain'),
    'name_admin_bar'        => _x('Book', 'Add New on Toolbar', 'textdomain'),
    'add_new'               => __('Add New', 'textdomain'),
    'add_new_item'          => __('Add New Book', 'textdomain'),
    'new_item'              => __('New Book', 'textdomain'),
    'edit_item'             => __('Edit Book', 'textdomain'),
    'view_item'             => __('View Book', 'textdomain'),
    'all_items'             => __('All Books', 'textdomain'),
    'search_items'          => __('Search Books', 'textdomain'),
    'parent_item_colon'     => __('Parent Books:', 'textdomain'),
    'not_found'             => __('No books found.', 'textdomain'),
    'not_found_in_trash'    => __('No books found in Trash.', 'textdomain'),
    'featured_image'        => _x('Book Cover Image', 'Overrides the “Featured Image” phrase for this post type. Added in 4.3', 'textdomain'),
    'set_featured_image'    => _x('Set cover image', 'Overrides the “Set featured image” phrase for this post type. Added in 4.3', 'textdomain'),
    'remove_featured_image' => _x('Remove cover image', 'Overrides the “Remove featured image” phrase for this post type. Added in 4.3', 'textdomain'),
    'use_featured_image'    => _x('Use as cover image', 'Overrides the “Use as featured image” phrase for this post type. Added in 4.3', 'textdomain'),
    'archives'              => _x('Book archives', 'The post type archive label used in nav menus. Default “Post Archives”. Added in 4.4', 'textdomain'),
    'insert_into_item'      => _x('Insert into book', 'Overrides the “Insert into post”/”Insert into page” phrase (used when inserting media into a post). Added in 4.4', 'textdomain'),
    'uploaded_to_this_item' => _x('Uploaded to this book', 'Overrides the “Uploaded to this post”/”Uploaded to this page” phrase (used when viewing media attached to a post). Added in 4.4', 'textdomain'),
    'filter_items_list'     => _x('Filter books list', 'Screen reader text for the filter links heading on the post type listing screen. Default “Filter posts list”/”Filter pages list”. Added in 4.4', 'textdomain'),
    'items_list_navigation' => _x('Books list navigation', 'Screen reader text for the pagination heading on the post type listing screen. Default “Posts list navigation”/”Pages list navigation”. Added in 4.4', 'textdomain'),
    'items_list'            => _x('Books list', 'Screen reader text for the items list heading on the post type listing screen. Default “Posts list”/”Pages list”. Added in 4.4', 'textdomain'),
  );
  $args = array(
    'labels'             => $labels,
    'public' => true,
    'publicly_queryable' => true,
    'show_ui' => true,
    'show_in_menu'       => true,
    'query_var'          => true,
    'capability_type'    => 'post',
    'rewrite'            => array('slug' => 'books'),
    'label'  => __('Books', 'textdomain'),
    'has_archive'        => true,
    'hierarchical'       => false,
    'menu_position'      => null,
    'show_in_graphql' => true,
    'graphql_single_name' => 'books',
    'graphql_plural_name' => 'books',
    'supports'           => array('title', 'editor', 'author', 'thumbnail', 'excerpt', 'comments')
  );
  register_post_type('books', $args);
}
add_action('init', 'wpdocs_codex_custom_init');
add_filter('register_post_type_args', function ($args, $post_type) {
  // Change this to the post type you are adding support for
  if ('book' === $post_type) {
    $args['show_in_graphql'] = true;
    $args['graphql_single_name'] = 'book';
    $args['graphql_plural_name'] = 'books'; # Don't set, and it will default to `all${graphql_single_name}`, i.e. `allDocument`.
  }
  return $args;
}, 10, 2);

[Single Post]

app\blog[slug]\page.tsx

import getAllPosts from '@/lib/queries/getAllPosts'
import getPostBySlug from '@/lib/queries/getPostBySlug'
import type {DynamicPageProps} from '@/lib/types'
import {Metadata} from 'next'
import Image from 'next/image'
import Link from 'next/link'
import {notFound} from 'next/navigation'
/**
 * Generate the static routes at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params
 */
export async function generateStaticParams() {
  // Get all blog posts.
  const posts = await getAllPosts()
  // No posts? Bail...
  if (!posts) {
    return []
  }
  // Return the slugs for each post.
  return posts.map((post: {slug: string}) => ({
    slug: post.slug
  }))
}
/**
 * Generate the metadata for each static route at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function
 */
export async function generateMetadata({
  params
}: DynamicPageProps): Promise<Metadata | null> {
  // Get the slug from the params.
  const {slug} = await params
  // Get the blog post.
  const post = await getPostBySlug(slug)
  // No post? Bail...
  if (!post) {
    return {}
  }
  return {
    title: post.seo.title,
    description: post.seo.metaDesc
  }
}
/**
 * The blog post route.
 *
 * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages
 */
export default async function Post({params}: Readonly<DynamicPageProps>) {
  // Get the slug from the params.
  const {slug} = await params
  // Fetch a single post from WordPress.
  const post = await getPostBySlug(slug)
  // No post? Bail...
  if (!post) {
    notFound()
  }
  return (
    <article>
      <header>
        <h2 dangerouslySetInnerHTML={{__html: post.title}} />
        <p className="italic">
          By {post.author.node.name} on <time>{post.date}</time>
        </p>
      </header>
      <div dangerouslySetInnerHTML={{__html: post.content}} />
      <footer className="flex items-center justify-between gap-4 pb-4">
        <div>
          <h3>Categories</h3>
          <ul className="m-0 flex list-none gap-2 p-0">
            {post.categories.nodes.map((category) => (
              <li className="m-0 p-0" key={category.databaseId}>
                <Link href={`/blog/category/${category.name}`}>
                  {category.name}
                </Link>
              </li>
            ))}
          </ul>
        </div>
        <div>
          <h3>Tags</h3>
          <ul className="m-0 flex list-none gap-2 p-0">
            {post.tags.nodes.map((tag) => (
              <li className="m-0 p-0" key={tag.databaseId}>
                <Link href={`/blog/tag/${tag.name}`}>{tag.name}</Link>
              </li>
            ))}
          </ul>
        </div>
      </footer>
      <section className="border-t-2">
        <h3>Comments</h3>
        {post.comments.nodes.map((comment) => (
          <article key={comment.databaseId}>
            <header className="flex items-center gap-2">
              <Image
                alt={comment.author.node.name}
                className="m-0 rounded-full"
                height={64}
                loading="lazy"
                src={comment.author.node.avatar.url}
                width={64}
              />
              <div className="flex flex-col gap-2">
                <h4
                  className="m-0 p-0 leading-none"
                  dangerouslySetInnerHTML={{__html: comment.author.node.name}}
                />
                <time className="italic">{comment.date}</time>
              </div>
            </header>
            <div dangerouslySetInnerHTML={{__html: comment.content}} />
          </article>
        ))}
      </section>
    </article>
  )
}

lib\queries\getPostBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Post} from '@/lib/types'

/**
 * Fetch a single blog post by slug.
 */
export default async function getPostBySlug(slug: string) {
  const query = `
    query GetPost($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        databaseId
        date
        modified
        content(format: RENDERED)
        title(format: RENDERED)
        featuredImage {
          node {
            altText
            sourceUrl
            mediaDetails {
                height
                width
            }
          }
        }
        author {
          node {
            name
            avatar {
              url
            }
          }
        }
        tags {
          nodes {
            databaseId
            name
          }
        }
        categories {
          nodes {
            databaseId
            name
          }
        }
        seo {
          metaDesc
          title
        }
        comments(first: 30, where: {order: ASC}) {
          nodes {
            content(format: RENDERED)
            databaseId
            date
            status
            author {
              node {
                avatar {
                  url
                }
                email
                name
                url
              }
            }
          }
        }
      }
    }
  `

  const variables = {
    slug: slug
  }

  const response = await fetchGraphQL(query, variables)

  return response.data.post as Post
}

[Custom Post Type]

app\books[slug]\page.tsx

import getAllBooks from '@/lib/queries/getAllBooks'
import getBookBySlug from '@/lib/queries/getBookBySlug'
import type {DynamicPageProps} from '@/lib/types'
import {Metadata} from 'next'
import Link from 'next/link'
import {notFound} from 'next/navigation'
/**
 * Generate the static routes at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params
 */
export async function generateStaticParams() {
  // Get a list of all books.
  const books = await getAllBooks()
  // No books? Bail...
  if (!books) {
    return []
  }
  // Return the slugs for each book.
  return books.map((book: {slug: string}) => ({
    slug: book.slug
  }))
}
/**
 * Generate the metadata for each static route at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function
 */
export async function generateMetadata({ params }: Readonly<DynamicPageProps>): Promise<Metadata | null> {
  // Get the slug from the params.
  const {slug} = await params
  // Get the page.
  const book = await getBookBySlug(slug)
  // No post? Bail...
  if (!book) {
    return {}
  }
  return {
    title: book.seo.title,
    description: book.seo.metaDesc
  }
}
/**
 * A single book route.
 *
 * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages
 */
export default async function Book({params}: Readonly<DynamicPageProps>) {
  // Get the slug from the params.
  const {slug} = await params
  // Fetch a single book from WordPress.
  const book = await getBookBySlug(slug)
  // No book? Bail...
  if (!book) {
    notFound()
  }
  return (
    <main className="flex flex-col gap-8">
      <article className="w-full">
        <h1 className="font-bold" dangerouslySetInnerHTML={{__html: book.title}} />
        <div dangerouslySetInnerHTML={{__html: book.content}} />
        <Link className="button" href={book.bookfields.affiliateurl}>View on Amazon</Link>
      </article>
    </main>
  )
}

lib\queries\getAllBooks.ts

import {fetchGraphQL} from '@/lib/functions'
import {Post} from '@/lib/types'
/**
 * Fetch all books.
 */
export default async function getAllBooks() {
  const query = `
    query GetAllBooks {
      books(where: {status: PUBLISH}) {
        nodes {
          databaseId
          date
          modified
          title
          slug
          excerpt(format: RENDERED)
          featuredImage {
            node {
              altText
              sourceUrl
              mediaDetails {
                  height
                  width
              }
            }
          }
          seo {
            metaDesc
            title
          }
        }
      }
    }
  `
  const response = await fetchGraphQL(query)
  return response.data.books.nodes as Post[]
}

lib\queries\getBookBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Book} from '@/lib/types'
/**
 * Fetch a book by slug.
 */
export default async function getBookBySlug(slug: string) {
  const query = `
    query GetBookBySlug($slug: ID = "URI") {
      book(idType: SLUG, id: $slug) {
        databaseId
        date
        modified
        content(format: RENDERED)
        title(format: RENDERED)
        bookfields {
          affiliateurl
          isbn
        }
        featuredImage {
          node {
            altText
            sourceUrl
            mediaDetails {
                height
                width
            }
          }
        }
        seo {
          metaDesc
          title
        }
      }
    }
  `
  const variables = {
    slug: slug
  }
  const response = await fetchGraphQL(query, variables)
  return response.data.book as Book
}

[Page and Blog]

— blog

— books

app\[slug]\page.tsx

import getAllBooks from '@/lib/queries/getAllBooks'
import getAllPosts from '@/lib/queries/getAllPosts'
import getPageBySlug from '@/lib/queries/getPageBySlug'
import type { DynamicPageProps } from '@/lib/types'
import { Page, Post } from '@/lib/types'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
/**
 * Fetches data from WordPress.
 */
async function fetchData(slug: string) {
  // If the slug is 'blog', fetch all posts.
  if (slug === 'blog') {
    return { posts: await getAllPosts(), context: 'blog' }
  }
  // If the slug is 'books', fetch all books.
  if (slug === 'books') {
    return { posts: await getAllBooks(), context: 'books' }
  }
  // Otherwise, this could be a page.
  const page = await getPageBySlug(slug)
  // If page data exists, return it.
  if (page) {
    return { post: page }
  }
  // Otherwise, return an error.
  return { error: 'No data found' }
}
/**
 * Render a single page.
 */
function RenderPage({ page }: { page: Page }) {
  return (
    <main className="flex flex-col gap-8">
      <article>
        <h1 dangerouslySetInnerHTML={{ __html: page.title }} />
        <div dangerouslySetInnerHTML={{ __html: page.content }} />
      </article>
    </main>
  )
}
/**
 * Render posts list.
 */
function RenderPostsList({ posts, context }: { posts: Post[]; context: string }) {
  return (
    <main className="flex flex-col gap-8">
      <h1 className="capitalize">Latest {context}</h1>
      <div className="grid grid-cols-4 gap-4">
        {posts.map((post: Post) => (
          <article className="p-4 shadow-md relative" key={post.databaseId}>
            {post?.featuredImage ? (
              <Image
                src={post?.featuredImage?.node?.sourceUrl}
                alt={post?.featuredImage?.node?.altText}
                width={280}
                height={233}
                className="h-[233px]"
                priority={true}
              />
            ) : (
              <img
                src={process.env.NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL as string}
                alt={"Featured"}
                width={280}
                height={233}
                className="h-[233px]"
              />
            )}
            <Link href={`/${context}/${post.slug}`}>
              <h2 className="font-bold py-2" dangerouslySetInnerHTML={{ __html: post.title }} />
            </Link>
            <p className="text-sm text-gray-500 italic">
              {post.commentCount} Comments
            </p>
            <div className="mb-2" dangerouslySetInnerHTML={{ __html: post.excerpt }} />
            <Link className=" bg-sky-500 hover:bg-sky-700 px-5 py-2 text-sm leading-5 rounded-full font-semibold text-white" href={`/${context}/${post.slug}`}>
              View Post
            </Link>
          </article>
        ))}
      </div>
    </main>
  )
}
/**
 * Catch-all Archive Page route.
 */
export default async function Archive({ params }: Readonly<DynamicPageProps>) {
  // Get the slug from the params.
  const { slug } = await params
  // Fetch data from WordPress.
  const data = await fetchData(slug)
  // If there's an error, return a 404 page.
  if (data.error) {
    notFound()
  }
  // If this is a single page, render the page.
  if (data.post) {
    return <RenderPage page={data.post} />
  }
  // Otherwise, this must be an archive. Render the posts list.
  if (data.posts && data.posts.length > 0) {
    return <RenderPostsList posts={data.posts} context={data.context} />
  }
  // Otherwise, return a 404 page.
  notFound()
}

[Category]

app\blog\category[slug]\page.tsx

import config from '@/lib/config'
import getCategoryBySlug from '@/lib/queries/getCategoryBySlug'
import {DynamicPageProps} from '@/lib/types'
import {Metadata} from 'next'
import Image from 'next/image'
import Link from 'next/link'
import {notFound} from 'next/navigation'

/**
 * Generate the metadata for each static route at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function
 */
export async function generateMetadata({
  params
}: DynamicPageProps): Promise<Metadata | null> {
  // Get the slug from the params.
  const {slug} = await params

  return {
    title: `${slug} Archives - ${config.siteName}`,
    description: `The category archive for ${slug}`
  }
}

/**
 * The category archive route.
 *
 * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages
 */
export default async function CategoryArchive({
  params
}: Readonly<DynamicPageProps>) {
  // Get the slug from the params.
  const {slug} = await params

  // Fetch posts from WordPress.
  const posts = await getCategoryBySlug(slug)

  // No posts? Bail...
  if (!posts) {
    notFound()
  }

  return (
    <main className="flex flex-col gap-8">
      <h1 className="capitalize">Post Category: {slug}</h1>
      <div className="grid grid-cols-4 gap-4">
        {posts.map((post) => (
          <article className="p-4 shadow-md" key={post.databaseId}>
            {post?.featuredImage ? (
              <Image
                src={post?.featuredImage?.node?.sourceUrl}
                alt={post?.featuredImage?.node?.altText}
                width={280}
                height={233}
                className="h-[233px]"
                priority={true}
              />
            ) : (
              <img
                src={process.env.NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL as string}
                alt={"Featured"}
                width={280}
                height={233}
                className="h-[233px]"
              />
            )}
            <Link href={`/blog/${post.slug}`}>
              <h2 className="font-bold py-2" dangerouslySetInnerHTML={{__html: post.title}} />
            </Link>
            <p className="text-sm text-gray-500">
              {post.commentCount} Comments
            </p>
            <div className="mb-2" dangerouslySetInnerHTML={{__html: post.excerpt}} />
            <Link className="bg-sky-500 hover:bg-sky-700 px-5 py-2 text-sm leading-5 rounded-full font-semibold text-white" href={`/blog/${post.slug}`}>
              View Post
            </Link>
          </article>
        ))}
      </div>
    </main>
  )
}

lib\queries\getCategoryBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Post} from '@/lib/types'
/**
 * Fetch a category archive by slug.
 */
export default async function getCategoryBySlug( slug: string, limit: number = 10 ) {
  const query = `
    query GetCategoryBySlug($slug: String!) {
      posts(where: {categoryName: $slug, status: PUBLISH}, first: ${limit}) {
        nodes {
          databaseId
          date
          excerpt(format: RENDERED)
          title(format: RENDERED)
          featuredImage {
            node {
              altText
              sourceUrl
              mediaDetails {
                height
                width
              }
            }
          }
          seo {
            metaDesc
            title
          }
          slug
        }
      }
    }
  `
  const variables = {
    slug: slug
  }
  const response = await fetchGraphQL(query, variables)
  return response.data.posts.nodes as Post[]
}

[Tag]

app\blog\tag[slug]\page.tsx

import config from '@/lib/config'
import getTagBySlug from '@/lib/queries/getTagBySlug'
import type {DynamicPageProps} from '@/lib/types'
import {Metadata} from 'next'
import Image from 'next/image'
import Link from 'next/link'
import {notFound} from 'next/navigation'

/**
 * Generate the metadata for each static route at build time.
 *
 * @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata#generatemetadata-function
 */
export async function generateMetadata({
  params
}: Readonly<DynamicPageProps>): Promise<Metadata | null> {
  // Get the slug from the params.
  const {slug} = await params

  return {
    title: `${slug} Archives - ${config.siteName}`,
    description: `The tag archive for ${slug}`
  }
}

/**
 * The tag archive route.
 *
 * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages
 */
export default async function TagArchive({params}: Readonly<DynamicPageProps>) {
  // Get the slug from the params.
  const {slug} = await params

  // Fetch posts from WordPress.
  const posts = await getTagBySlug(slug)

  // No posts? Bail...
  if (!posts) {
    notFound()
  }

  return (
    <main className="flex flex-col gap-8">
      <h1 className="capitalize">Post Tag: {slug}</h1>
      <div className="grid grid-cols-4 gap-4">
        {posts.map((post) => (
          <article className="p-4 drop-shadow-lg border" key={post.databaseId}>
            {post?.featuredImage ? (
              <Image
                src={post?.featuredImage?.node?.sourceUrl}
                alt={post?.featuredImage?.node?.altText}
                width={280}
                height={233}
                className="h-[233px]"
                priority={true}
              />
            ) : (
              <img
                src={process.env.NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL as string}
                alt={"Featured"}
                width={280}
                height={233}
                className="h-[233px]"
              />
            )}
            <Link href={`/blog/${post.slug}`}>
              <h2 className="font-bold py-2" dangerouslySetInnerHTML={{__html: post.title}} />
            </Link>
            <p className="text-sm text-gray-500">
              {post.commentCount} Comments
            </p>
            <div className="mb-2" dangerouslySetInnerHTML={{__html: post.excerpt.substring(0, 120)}} />
            <Link className="bg-sky-500 hover:bg-sky-700 px-5 py-2 text-sm leading-5 rounded-full font-semibold text-white" href={`/blog/${post.slug}`}>
              View Post
            </Link>
          </article>
        ))}
      </div>
    </main>
  )
}

lib\queries\getTagBySlug.ts

import {fetchGraphQL} from '@/lib/functions'
import {Post} from '@/lib/types'

/**
 * Fetch a tag archive by slug.
 */
export default async function getTagBySlug(slug: string, limit: number = 10) {
  const query = `
    query GetTagBySlug($slug: String!) {
      posts(where: {tag: $slug, status: PUBLISH}, first: ${limit}) {
        nodes {
          databaseId
          date
          excerpt(format: RENDERED)
          title(format: RENDERED)
          featuredImage {
            node {
              altText
              sourceUrl
              mediaDetails {
                  height
                  width
              }
            }
          }
          seo {
            metaDesc
            title
          }
          slug
        }
      }
    }
  `

  const variables = {
    slug: slug
  }

  const response = await fetchGraphQL(query, variables)

  return response.data.posts.nodes as Post[]
}

[Comment]

lib\mutations\createComment.ts

import { fetchGraphQL } from '@/lib/functions'
/**
 * Create a comment.
 */
export async function createComment(comment: { name: string, email: string, website: string, comment: string, postID: string }) {
  const query = `
    mutation CREATE_COMMENT(
      $authorEmail: String!
      $authorName: String!
      $authorUrl: String
      $comment: String!
      $postID: Int!
    ) {
      createComment(
        input: {
          author: $authorName
          authorEmail: $authorEmail
          authorUrl: $authorUrl
          commentOn: $postID
          content: $comment
        }
      ) {
        success
        comment {
          author {
            node {
              avatar {
                url
              }
              email
              name
              url
            }
          }
          content(format: RENDERED)
          date
        }
      }
    }
  `
  const variables = {
    authorEmail: comment.email,
    authorName: comment.name,
    authorUrl: comment.website,
    comment: comment.comment,
    postID: comment.postID
  }
  const response = await fetchGraphQL(query, variables)
  return response.data.createComment
}

app\search\page.tsx

import SearchForm from '@/components/SearchForm'
/**
 * Search page.
 */
export default function Page() {
  return (
    <main className="flex flex-col gap-8">
      <h1>Search</h1>
      <SearchForm />
    </main>
  )
}

components\SearchForm.tsx

'use client'
import {searchQuery} from '@/lib/functions'
import {SearchResults} from '@/lib/types'
import Link from 'next/link'
import {useCallback, useEffect, useRef, useState} from 'react'
/**
 * Search component.
 */
export default function Search() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<SearchResults[]>([])
  const [isSearching, setIsSearching] = useState(false)
  const [hasSearched, setHasSearched] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)
  // Perform the search.
  const performSearch = useCallback(async () => {
    // If the query is empty or too long, return early.
    if (query.length === 0 || query.length > 100) return
    setIsSearching(true)
    setHasSearched(true)
    try {
      const data = await searchQuery(query)
      setResults(data)
    } catch (error) {
      console.error(error)
      setResults([])
    } finally {
      setIsSearching(false)
    }
  }, [query])
  // Debounce the search query.
  useEffect(() => {
    if (query.length > 0) {
      const debounceTimeout = setTimeout(performSearch, 500)
      return () => clearTimeout(debounceTimeout)
    } else {
      setResults([])
      setHasSearched(false)
    }
  }, [query, performSearch])
  // Reset the search.
  function resetSearch() {
    setIsSearching(false)
    setQuery('')
    setResults([])
    setHasSearched(false)
  }
  return (
    <>
      <div className="relative flex items-center pb-8">
        <input
          aria-label="Search"
          className="w-full block rounded-full bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-sky-500 sm:text-sm/6"
          name="search"
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Begin typing to search..."
          ref={inputRef}
          type="search"
          value={query}
        />
        <button className="absolute right-0 bg-sky-500 hover:bg-sky-700 px-5 py-2 text-sm leading-5 rounded-full font-semibold text-white" onClick={resetSearch} type="reset">
          Reset
        </button>
      </div>
      {query.length > 0 && !hasSearched && <p className="m-0">Searching...</p>}
      {!isSearching && hasSearched && results.length === 0 && (
        <p className="m-0">Bummer. No results found.</p>
      )}
      {!isSearching && results.length > 0 && (
        <div>
          <p className="m-0">
            Nice! You found{' '}
            <span className="border-b border-b-orange-300 font-bold">
              {results.length}
            </span>{' '}
            results for{' '}
            <span className="bg-orange-300 p-1 text-zinc-800">{query}</span>
          </p>
          <ol className='grid grid-cols-3 gap-4 p-2 rounded-md'>
            {results?.map((result) => (
              <li key={result.id}>
                <Link
                  href={result.url.replace('https://blog.', 'https://')}
                  onClick={resetSearch}
                >
                  <span
                    className="m-0 p-0"
                    dangerouslySetInnerHTML={{__html: result.title}}
                  />
                </Link>
              </li>
            ))}
          </ol>
        </div>
      )}
    </>
  )
}

feed.xml

app\feed.xml\route.ts

import config from '@/lib/config'
import getAllPosts from '@/lib/queries/getAllPosts'
import escape from 'xml-escape'

/**
 * Route handler for generating RSS feed.
 *
 * @see https://nextjs.org/docs/app/api-reference/file-conventions/route
 */
export async function GET() {
  // Fetch all posts.
  const allPosts = await getAllPosts()

  // If no posts, return response.
  if (!allPosts) {
    return new Response('No posts found.', {
      headers: {
        'Content-Type': 'application/xml; charset=utf-8'
      }
    })
  }

  // Start of RSS feed.
  let rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>${config.siteName}</title>
    <description>${config.siteDescription}</description>
    <link>${config.siteUrl}</link>
    <generator>RSS for Node and Next.js</generator>
    <pubDate>${new Date().toUTCString()}</pubDate>
    <ttl>60</ttl>`

  // Add posts to RSS feed.
  allPosts.forEach((post) => {
    rss += `
    <item>
      <title>${escape(post.title)}</title>
      <description>${escape(post.excerpt)}</description>
      <link>${config.siteUrl}/blog/${post.slug}</link>
      <guid>${config.siteUrl}/blog/${post.slug}</guid>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
    </item>`
  })

  // Close channel and rss tag.
  rss += `
  </channel>
</rss>`

  // Return response.
  return new Response(rss, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8'
    }
  })
}

sitemap

wp-content\themes\astra-child\functions.php

add_post_type_support( 'page', 'excerpt' );

app\sitemap.ts

import config from '@/lib/config'
import getAllPages from '@/lib/queries/getAllPages'
import getAllPosts from '@/lib/queries/getAllPosts'
import type { Page, Post } from '@/lib/types'
import type { MetadataRoute } from 'next'
/**
 * Generate sitemap entries from WordPress pages and posts.
 */
function generateWPEntries(items: Post[] | Page[],urlPrefix: string): MetadataRoute.Sitemap {
  return items.map((item) => ({
    url: `${config.siteUrl}${urlPrefix}${item.slug}`,
    lastModified: new Date(),
    changeFrequency: 'monthly',
    priority: 0.5
  }))
}
/**
 * Generate sitemap for the website.
 *
 * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-a-sitemap-using-code-js-ts
 */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  try {
    // Fetch all posts and pages
    const allPosts = await getAllPosts()
    const allPages = await getAllPages()
    // Generate sitemap entries.
    const posts = generateWPEntries(allPosts, '/blog/')
    const pages = generateWPEntries(allPages, '/')
    // Combine posts and pages.
    const sitemap = [...pages, ...posts];
    console.log(sitemap)
    // Create sitemap.xml
    return sitemap
  } catch (error) {
    console.error('Error generating sitemap:', error)
    return []
  }
}

robots.ts

import { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : 'http://localhost:3000';
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*'
      }
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
    host: baseUrl
  };
}

Last updated

Was this helpful?