❓Tìm hiểu SEO feed.xml, sitemap.ts, robots.ts nextjs-wordpress
Last updated
Was this helpful?
Last updated
Was this helpful?
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?s`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"
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[]
}
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
}
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);
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
}
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
}
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()
}
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[]
}
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>
)}
</>
)
}
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'
}
})
}
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
};
}