├── app ├── robots.txt ├── favicon.ico ├── opengraph-image.jpeg ├── twitter-image.jpeg ├── not-found.tsx ├── posts │ ├── tags │ │ └── page.tsx │ ├── authors │ │ └── page.tsx │ ├── categories │ │ └── page.tsx │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── pages │ ├── page.tsx │ └── [slug] │ │ └── page.tsx ├── sitemap.ts ├── api │ ├── og │ │ └── route.tsx │ └── revalidate │ │ └── route.ts ├── globals.css ├── layout.tsx └── page.tsx ├── plugin ├── next-revalidate │ ├── index.php │ ├── README.txt │ └── next-revalidate.php ├── next-revalidate.zip └── README.md ├── .eslintrc.json ├── wordpress ├── next-revalidate │ ├── index.php │ └── next-revalidate.php ├── theme │ ├── style.css │ ├── index.php │ └── functions.php ├── Dockerfile ├── entrypoint.sh └── setup.sh ├── postcss.config.js ├── lib ├── types.d.ts ├── utils.ts ├── wordpress.d.ts └── wordpress.ts ├── railway.toml ├── .env.example ├── railway.json ├── menu.config.ts ├── site.config.ts ├── components ├── back.tsx ├── theme │ ├── theme-provider.tsx │ └── theme-toggle.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── separator.tsx │ ├── badge.tsx │ ├── scroll-area.tsx │ ├── button.tsx │ ├── pagination.tsx │ ├── sheet.tsx │ ├── form.tsx │ ├── navigation-menu.tsx │ ├── select.tsx │ └── dropdown-menu.tsx ├── posts │ ├── search-input.tsx │ ├── post-card.tsx │ └── filter.tsx ├── icons │ ├── wordpress.tsx │ └── nextjs.tsx ├── nav │ └── mobile-nav.tsx └── craft.tsx ├── .dockerignore ├── components.json ├── .claude └── settings.local.json ├── public ├── logo.svg ├── wordpress.svg └── next-js.svg ├── .gitignore ├── next.config.ts ├── tsconfig.json ├── LICENSE ├── Dockerfile ├── .github └── workflows │ ├── docker-wordpress.yml │ └── release-plugin.yml ├── package.json ├── CLAUDE.md └── README.md /app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /plugin/next-revalidate/index.php: -------------------------------------------------------------------------------- 1 | router.back()}> 11 | Go Back 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/theme/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { ThemeProviderProps } from "next-themes"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | 5 | # Build outputs 6 | .next 7 | out 8 | 9 | # Development 10 | .env 11 | .env.local 12 | .env.development 13 | .env.test 14 | 15 | # Git 16 | .git 17 | .gitignore 18 | 19 | # IDE 20 | .vscode 21 | .idea 22 | *.swp 23 | *.swo 24 | 25 | # Documentation (not needed in container) 26 | README.md 27 | CLAUDE.md 28 | 29 | # Plan files 30 | .claude 31 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(find:*)", 5 | "Bash(cat:*)", 6 | "WebFetch(domain:docs.railway.app)", 7 | "WebFetch(domain:docs.railway.com)", 8 | "WebSearch", 9 | "WebFetch(domain:github.com)", 10 | "WebFetch(domain:blog.railway.com)", 11 | "Bash(pnpm remove:*)", 12 | "Bash(pnpm build:*)", 13 | "Bash(mkdir:*)", 14 | "Bash(WORDPRESS_URL=\"\" WORDPRESS_HOSTNAME=\"\" pnpm build:*)" 15 | ], 16 | "deny": [], 17 | "ask": [] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Ignore all env files 29 | .env* 30 | # But do not ignore .env.example 31 | !.env.example 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | .claude 41 | .factory 42 | RAILWAY-SETUP.md 43 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Section, Container } from "@/components/craft"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | import Link from "next/link"; 5 | 6 | export default function NotFound() { 7 | return ( 8 |
9 | 10 |
11 |

404 - Page Not Found

12 |

13 | Sorry, the page you are looking for does not exist. 14 |

15 | 18 |
19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const wordpressHostname = process.env.WORDPRESS_HOSTNAME; 4 | const wordpressUrl = process.env.WORDPRESS_URL; 5 | 6 | const nextConfig: NextConfig = { 7 | output: "standalone", 8 | images: { 9 | remotePatterns: wordpressHostname 10 | ? [ 11 | { 12 | protocol: "https", 13 | hostname: wordpressHostname, 14 | port: "", 15 | pathname: "/**", 16 | }, 17 | ] 18 | : [], 19 | }, 20 | async redirects() { 21 | if (!wordpressUrl) { 22 | return []; 23 | } 24 | return [ 25 | { 26 | source: "/admin", 27 | destination: `${wordpressUrl}/wp-admin`, 28 | permanent: true, 29 | }, 30 | ]; 31 | }, 32 | }; 33 | 34 | export default nextConfig; 35 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/theme/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | export function ThemeToggle() { 9 | const { theme, setTheme } = useTheme(); 10 | 11 | const handleClick = () => { 12 | if (theme === "light") setTheme("dark"); 13 | else setTheme("light"); 14 | }; 15 | 16 | return ( 17 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "react-jsx", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | "wp.config.ts", 37 | ".next/dev/types/**/*.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/posts/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | 5 | import { useSearchParams, useRouter, usePathname } from "next/navigation"; 6 | import { useDebouncedCallback } from "use-debounce"; 7 | 8 | export function SearchInput({ defaultValue }: { defaultValue?: string }) { 9 | const searchParams = useSearchParams(); 10 | const pathname = usePathname(); 11 | const { replace } = useRouter(); 12 | 13 | const handleSearch = useDebouncedCallback((term: string) => { 14 | const params = new URLSearchParams(searchParams); 15 | if (term) { 16 | params.set("search", term); 17 | } else { 18 | params.delete("search"); 19 | } 20 | replace(`${pathname}?${params.toString()}`); 21 | }, 300); 22 | 23 | return ( 24 | handleSearch(e.target.value)} 30 | /> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /wordpress/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM wordpress:latest 2 | 3 | # Labels for GitHub Container Registry 4 | LABEL org.opencontainers.image.source="https://github.com/9d8dev/next-wp" 5 | LABEL org.opencontainers.image.description="WordPress with next-revalidate plugin for headless Next.js" 6 | LABEL org.opencontainers.image.licenses="MIT" 7 | 8 | # Install WP-CLI 9 | RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \ 10 | && chmod +x wp-cli.phar \ 11 | && mv wp-cli.phar /usr/local/bin/wp 12 | 13 | # Copy plugin and theme to staging location (volume overwrites /var/www/html) 14 | COPY next-revalidate /usr/src/next-revalidate 15 | COPY theme /usr/src/nextjs-headless 16 | 17 | # Copy the setup scripts 18 | COPY setup.sh /usr/local/bin/setup-wordpress.sh 19 | COPY entrypoint.sh /usr/local/bin/custom-entrypoint.sh 20 | RUN chmod +x /usr/local/bin/setup-wordpress.sh /usr/local/bin/custom-entrypoint.sh 21 | 22 | ENTRYPOINT ["/usr/local/bin/custom-entrypoint.sh"] 23 | CMD ["apache2-foreground"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 9d8 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/posts/tags/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllTags } from "@/lib/wordpress"; 2 | import { Section, Container, Prose } from "@/components/craft"; 3 | import { Metadata } from "next"; 4 | import BackButton from "@/components/back"; 5 | import Link from "next/link"; 6 | 7 | export const revalidate = 3600; 8 | 9 | export const metadata: Metadata = { 10 | title: "All Tags", 11 | description: "Browse all tags of our blog posts", 12 | alternates: { 13 | canonical: "/posts/tags", 14 | }, 15 | }; 16 | 17 | export default async function Page() { 18 | const tags = await getAllTags(); 19 | 20 | return ( 21 |
22 | 23 | 24 |

All Tags

25 | {tags.length > 0 ? ( 26 |
    27 | {tags.map((tag: any) => ( 28 |
  • 29 | {tag.name} 30 |
  • 31 | ))} 32 |
33 | ) : ( 34 |

No tags available yet.

35 | )} 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/pages/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllPages } from "@/lib/wordpress"; 2 | import { Section, Container, Prose } from "@/components/craft"; 3 | import { Metadata } from "next"; 4 | import BackButton from "@/components/back"; 5 | import Link from "next/link"; 6 | 7 | export const revalidate = 3600; 8 | 9 | export const metadata: Metadata = { 10 | title: "All Pages", 11 | description: "Browse all pages of our blog posts", 12 | alternates: { 13 | canonical: "/posts/pages", 14 | }, 15 | }; 16 | 17 | export default async function Page() { 18 | const pages = await getAllPages(); 19 | 20 | return ( 21 |
22 | 23 | 24 |

All Pages

25 | {pages.length > 0 ? ( 26 |
    27 | {pages.map((page: any) => ( 28 |
  • 29 | {page.title.rendered} 30 |
  • 31 | ))} 32 |
33 | ) : ( 34 |

No pages available yet.

35 | )} 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Dependencies 2 | FROM node:20-alpine AS deps 3 | RUN corepack enable && corepack prepare pnpm@latest --activate 4 | WORKDIR /app 5 | COPY package.json pnpm-lock.yaml ./ 6 | RUN pnpm install --frozen-lockfile 7 | 8 | # Stage 2: Builder 9 | FROM node:20-alpine AS builder 10 | RUN corepack enable && corepack prepare pnpm@latest --activate 11 | WORKDIR /app 12 | COPY --from=deps /app/node_modules ./node_modules 13 | COPY . . 14 | 15 | # Build arguments for environment variables needed at build time 16 | ARG WORDPRESS_URL 17 | ARG WORDPRESS_HOSTNAME 18 | ENV WORDPRESS_URL=$WORDPRESS_URL 19 | ENV WORDPRESS_HOSTNAME=$WORDPRESS_HOSTNAME 20 | ENV NEXT_TELEMETRY_DISABLED=1 21 | 22 | RUN pnpm build 23 | 24 | # Stage 3: Runner 25 | FROM node:20-alpine AS runner 26 | WORKDIR /app 27 | 28 | ENV NODE_ENV=production 29 | ENV NEXT_TELEMETRY_DISABLED=1 30 | 31 | RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs 32 | 33 | COPY --from=builder /app/public ./public 34 | COPY --from=builder /app/.next/standalone ./ 35 | COPY --from=builder /app/.next/static ./.next/static 36 | 37 | USER nextjs 38 | 39 | EXPOSE 3000 40 | 41 | # Railway sets PORT dynamically - default to 3000 if not set 42 | ENV HOSTNAME="0.0.0.0" 43 | 44 | CMD ["node", "server.js"] 45 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /app/posts/authors/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllAuthors } from "@/lib/wordpress"; 2 | import { Section, Container, Prose } from "@/components/craft"; 3 | import { Metadata } from "next"; 4 | import BackButton from "@/components/back"; 5 | import Link from "next/link"; 6 | 7 | export const revalidate = 3600; 8 | 9 | export const metadata: Metadata = { 10 | title: "All Authors", 11 | description: "Browse all authors of our blog posts", 12 | alternates: { 13 | canonical: "/posts/authors", 14 | }, 15 | }; 16 | 17 | export default async function Page() { 18 | const authors = await getAllAuthors(); 19 | 20 | return ( 21 |
22 | 23 | 24 |

All Authors

25 | {authors.length > 0 ? ( 26 |
    27 | {authors.map((author: any) => ( 28 |
  • 29 | {author.name} 30 |
  • 31 | ))} 32 |
33 | ) : ( 34 |

No authors available yet.

35 | )} 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/docker-wordpress.yml: -------------------------------------------------------------------------------- 1 | name: Build WordPress Image 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'wordpress/**' 8 | workflow_dispatch: 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository_owner }}/next-wp-wordpress 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Log in to GitHub Container Registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata for Docker 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | tags: | 38 | type=raw,value=latest 39 | type=sha,prefix= 40 | 41 | - name: Build and push Docker image 42 | uses: docker/build-push-action@v5 43 | with: 44 | context: ./wordpress 45 | push: true 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /app/posts/categories/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAllCategories } from "@/lib/wordpress"; 2 | import { Section, Container, Prose } from "@/components/craft"; 3 | import { Metadata } from "next"; 4 | import BackButton from "@/components/back"; 5 | import Link from "next/link"; 6 | 7 | export const revalidate = 3600; 8 | 9 | export const metadata: Metadata = { 10 | title: "All Categories", 11 | description: "Browse all categories of our blog posts", 12 | alternates: { 13 | canonical: "/posts/categories", 14 | }, 15 | }; 16 | 17 | export default async function Page() { 18 | const categories = await getAllCategories(); 19 | 20 | return ( 21 |
22 | 23 | 24 |

All Categories

25 | {categories.length > 0 ? ( 26 |
    27 | {categories.map((category: any) => ( 28 |
  • 29 | 30 | {category.name} 31 | 32 |
  • 33 | ))} 34 |
35 | ) : ( 36 |

No categories available yet.

37 | )} 38 |
39 | 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/release-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Release Plugin 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - 'wordpress/next-revalidate/**' 8 | tags: 9 | - 'plugin-v*' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Create plugin ZIP 23 | run: | 24 | cd wordpress 25 | zip -r next-revalidate.zip next-revalidate/ 26 | 27 | - name: Upload artifact 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: next-revalidate-plugin 31 | path: wordpress/next-revalidate.zip 32 | 33 | - name: Create Release (on tag) 34 | if: startsWith(github.ref, 'refs/tags/') 35 | uses: softprops/action-gh-release@v1 36 | with: 37 | files: wordpress/next-revalidate.zip 38 | name: Next.js Revalidation Plugin ${{ github.ref_name }} 39 | body: | 40 | ## Next.js Revalidation Plugin 41 | 42 | WordPress plugin for automatic cache revalidation with Next.js. 43 | 44 | ### Installation 45 | 1. Download `next-revalidate.zip` 46 | 2. Go to WordPress Admin → Plugins → Add New → Upload Plugin 47 | 3. Upload the ZIP and activate 48 | 4. Configure at Settings → Next.js Revalidation 49 | -------------------------------------------------------------------------------- /public/wordpress.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /wordpress/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copy plugin and theme from staging to WordPress (after volume is mounted) 4 | copy_custom_files() { 5 | # Wait for WordPress files to be ready 6 | while [ ! -d /var/www/html/wp-content/plugins ]; do 7 | sleep 2 8 | done 9 | 10 | # Copy plugin if not already present 11 | if [ ! -d /var/www/html/wp-content/plugins/next-revalidate ]; then 12 | echo "Installing next-revalidate plugin..." 13 | cp -r /usr/src/next-revalidate /var/www/html/wp-content/plugins/ 14 | chown -R www-data:www-data /var/www/html/wp-content/plugins/next-revalidate 15 | fi 16 | 17 | # Copy theme if not already present 18 | if [ ! -d /var/www/html/wp-content/themes/nextjs-headless ]; then 19 | echo "Installing nextjs-headless theme..." 20 | cp -r /usr/src/nextjs-headless /var/www/html/wp-content/themes/ 21 | chown -R www-data:www-data /var/www/html/wp-content/themes/nextjs-headless 22 | fi 23 | 24 | # Create robots.txt to block all crawlers (Next.js is the public site) 25 | echo "Creating robots.txt..." 26 | cat > /var/www/html/robots.txt << 'EOF' 27 | User-agent: * 28 | Disallow: / 29 | EOF 30 | chown www-data:www-data /var/www/html/robots.txt 31 | 32 | # Run the setup script 33 | /usr/local/bin/setup-wordpress.sh 34 | } 35 | 36 | # Run copy and setup in background after a delay 37 | (sleep 10 && copy_custom_files) & 38 | 39 | # Run the original WordPress entrypoint 40 | exec docker-entrypoint.sh "$@" 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-wp", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbo", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.10.0", 13 | "@radix-ui/react-dialog": "^1.1.15", 14 | "@radix-ui/react-dropdown-menu": "^2.1.16", 15 | "@radix-ui/react-label": "^2.1.8", 16 | "@radix-ui/react-navigation-menu": "^1.2.14", 17 | "@radix-ui/react-scroll-area": "^1.2.10", 18 | "@radix-ui/react-select": "^2.2.6", 19 | "@radix-ui/react-separator": "^1.1.8", 20 | "@radix-ui/react-slot": "^1.2.4", 21 | "@vercel/analytics": "^1.5.0", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "lucide-react": "^0.469.0", 25 | "next": "16.0.10", 26 | "next-themes": "^0.4.6", 27 | "query-string": "^9.3.1", 28 | "react": "19.1.0", 29 | "react-dom": "19.1.0", 30 | "react-hook-form": "^7.66.1", 31 | "tailwind-merge": "^2.6.0", 32 | "use-debounce": "^10.0.6", 33 | "zod": "^3.25.76" 34 | }, 35 | "devDependencies": { 36 | "@tailwindcss/postcss": "^4.1.17", 37 | "@tailwindcss/typography": "^0.5.19", 38 | "@types/node": "^20.19.25", 39 | "@types/react": "^18.3.27", 40 | "@types/react-dom": "^18.3.7", 41 | "eslint": "^9.39.1", 42 | "eslint-config-next": "^15.5.6", 43 | "postcss": "^8.5.6", 44 | "tailwindcss": "^4.1.17", 45 | "typescript": "^5.9.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/next-js.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /wordpress/theme/index.php: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | Headless WordPress 17 | 39 | 40 | 41 |
42 |

Headless WordPress

43 |

This WordPress installation is configured for headless use.

44 | 45 |

Visit the frontend

46 | 47 |

WordPress Admin

48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next"; 2 | import { getAllPosts } from "@/lib/wordpress"; 3 | import { siteConfig } from "@/site.config"; 4 | 5 | export default async function sitemap(): Promise { 6 | const posts = await getAllPosts(); 7 | 8 | const staticUrls: MetadataRoute.Sitemap = [ 9 | { 10 | url: `${siteConfig.site_domain}`, 11 | lastModified: new Date(), 12 | changeFrequency: "yearly", 13 | priority: 1, 14 | }, 15 | { 16 | url: `${siteConfig.site_domain}/posts`, 17 | lastModified: new Date(), 18 | changeFrequency: "weekly", 19 | priority: 0.8, 20 | }, 21 | { 22 | url: `${siteConfig.site_domain}/pages`, 23 | lastModified: new Date(), 24 | changeFrequency: "monthly", 25 | priority: 0.5, 26 | }, 27 | { 28 | url: `${siteConfig.site_domain}/authors`, 29 | lastModified: new Date(), 30 | changeFrequency: "monthly", 31 | priority: 0.5, 32 | }, 33 | { 34 | url: `${siteConfig.site_domain}/categories`, 35 | lastModified: new Date(), 36 | changeFrequency: "monthly", 37 | priority: 0.5, 38 | }, 39 | { 40 | url: `${siteConfig.site_domain}/tags`, 41 | lastModified: new Date(), 42 | changeFrequency: "monthly", 43 | priority: 0.5, 44 | }, 45 | ]; 46 | 47 | const postUrls: MetadataRoute.Sitemap = posts.map((post) => ({ 48 | url: `${siteConfig.site_domain}/posts/${post.slug}`, 49 | lastModified: new Date(post.modified), 50 | changeFrequency: "weekly", 51 | priority: 0.5, 52 | })); 53 | 54 | return [...staticUrls, ...postUrls]; 55 | } 56 | -------------------------------------------------------------------------------- /components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /components/icons/wordpress.tsx: -------------------------------------------------------------------------------- 1 | export function WordPressIcon({ 2 | className, 3 | width, 4 | height, 5 | }: { 6 | className?: string; 7 | width?: number; 8 | height?: number; 9 | }) { 10 | return ( 11 | 19 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/icons/nextjs.tsx: -------------------------------------------------------------------------------- 1 | export function NextJsIcon({ 2 | className, 3 | width, 4 | height, 5 | }: { 6 | className?: string; 7 | width?: number; 8 | height?: number; 9 | }) { 10 | return ( 11 | 18 | 22 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /wordpress/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Wait for database to be ready 5 | until wp db check --allow-root 2>/dev/null; do 6 | echo "Waiting for database..." 7 | sleep 3 8 | done 9 | 10 | # Check if WordPress is already installed 11 | if ! wp core is-installed --allow-root 2>/dev/null; then 12 | echo "Installing WordPress..." 13 | 14 | wp core install \ 15 | --url="${WORDPRESS_URL:-http://localhost}" \ 16 | --title="${WORDPRESS_TITLE:-My WordPress Site}" \ 17 | --admin_user="${WORDPRESS_ADMIN_USER:-admin}" \ 18 | --admin_password="${WORDPRESS_ADMIN_PASSWORD:-changeme}" \ 19 | --admin_email="${WORDPRESS_ADMIN_EMAIL:-admin@example.com}" \ 20 | --skip-email \ 21 | --allow-root 22 | 23 | echo "WordPress installed successfully!" 24 | 25 | # Remove default plugins (keep only next-revalidate) 26 | echo "Removing default plugins..." 27 | wp plugin delete akismet --allow-root 2>/dev/null || true 28 | wp plugin delete hello --allow-root 2>/dev/null || true 29 | fi 30 | 31 | # Activate the revalidation plugin if not already active 32 | if ! wp plugin is-active next-revalidate --allow-root 2>/dev/null; then 33 | echo "Activating Next.js Revalidation plugin..." 34 | wp plugin activate next-revalidate --allow-root 35 | fi 36 | 37 | # Activate the headless theme (always run, safe if already active) 38 | # Note: Theme directory name must match the actual directory in wp-content/themes 39 | echo "Activating Next.js Headless theme..." 40 | wp theme activate theme --allow-root || wp theme activate nextjs-headless --allow-root || true 41 | 42 | # Configure the plugin if NEXTJS_URL is set 43 | if [ -n "$NEXTJS_URL" ]; then 44 | echo "Configuring Next.js Revalidation plugin..." 45 | # Plugin expects: nextjs_url, webhook_secret, cooldown (no enable_notifications field) 46 | wp option update next_revalidate_settings "{\"nextjs_url\":\"${NEXTJS_URL}\",\"webhook_secret\":\"${WORDPRESS_WEBHOOK_SECRET:-}\",\"cooldown\":2}" --format=json --allow-root 47 | echo "Plugin configured with Next.js URL: $NEXTJS_URL" 48 | fi 49 | 50 | echo "WordPress setup complete!" 51 | -------------------------------------------------------------------------------- /plugin/README.md: -------------------------------------------------------------------------------- 1 | # Next.js WordPress Revalidation Plugin 2 | 3 | This plugin enables automatic revalidation of your Next.js site when content is changed in WordPress. 4 | 5 | ## Installation 6 | 7 | 1. Upload the `next-revalidate.zip` file through the WordPress admin plugin installer, or 8 | 2. Extract the `next-revalidate` folder to your `/wp-content/plugins/` directory 9 | 3. Activate the plugin through the WordPress admin interface 10 | 4. Go to Settings > Next.js Revalidation to configure your settings 11 | 12 | ## Configuration 13 | 14 | ### 1. WordPress Plugin Settings 15 | 16 | After installing and activating the plugin: 17 | 18 | 1. Go to Settings > Next.js Revalidation in your WordPress admin 19 | 2. Enter your Next.js site URL (without trailing slash) 20 | 3. Create a secure webhook secret (a random string), you can use `openssl rand -base64 32` to generate one 21 | 4. Save your settings 22 | 23 | ### 2. Next.js Environment Variables 24 | 25 | Add the webhook secret to your Next.js environment variables: 26 | 27 | ```bash 28 | # .env.local 29 | WORDPRESS_WEBHOOK_SECRET="your-secret-key-here" 30 | ``` 31 | 32 | ## How It Works 33 | 34 | 1. When content in WordPress is created, updated, or deleted, the plugin sends a webhook to your Next.js API route 35 | 2. The webhook contains information about the content type (post, page, category, etc.) and ID 36 | 3. The Next.js API validates the request using the secret and revalidates the appropriate cache tags 37 | 4. Your Next.js site will fetch new content for the affected pages 38 | 39 | ## Features 40 | 41 | - Automatic revalidation for posts, pages, categories, tags, and media 42 | - Manual revalidation option through the admin interface 43 | - Secure webhook communication with your Next.js site 44 | - Optional admin notifications for revalidation events 45 | 46 | ## Troubleshooting 47 | 48 | If revalidation isn't working: 49 | 50 | 1. Check that your Next.js URL is correct in the plugin settings 51 | 2. Verify the webhook secret matches in both WordPress and Next.js 52 | 3. Check your server logs for any errors in the API route 53 | 4. Enable notifications in the plugin settings to see revalidation status 54 | -------------------------------------------------------------------------------- /wordpress/theme/functions.php: -------------------------------------------------------------------------------- 1 | 31 |
43 | {title} 44 |
45 | {description && ( 46 |
61 | {description} 62 |
63 | )} 64 |
65 | ), 66 | { 67 | width: 1200, 68 | height: 630, 69 | } 70 | ); 71 | } catch (e: any) { 72 | console.log(`${e.message}`); 73 | return new Response(`Failed to generate the image`, { 74 | status: 500, 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/pages/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPageBySlug, getAllPages } from "@/lib/wordpress"; 2 | import { Section, Container, Prose } from "@/components/craft"; 3 | import { siteConfig } from "@/site.config"; 4 | import { notFound } from "next/navigation"; 5 | 6 | import type { Metadata } from "next"; 7 | 8 | // Revalidate pages every hour 9 | export const revalidate = 3600; 10 | 11 | export async function generateStaticParams() { 12 | const pages = await getAllPages(); 13 | 14 | return pages.map((page) => ({ 15 | slug: page.slug, 16 | })); 17 | } 18 | 19 | export async function generateMetadata({ 20 | params, 21 | }: { 22 | params: Promise<{ slug: string }>; 23 | }): Promise { 24 | const { slug } = await params; 25 | const page = await getPageBySlug(slug); 26 | 27 | if (!page) { 28 | return {}; 29 | } 30 | 31 | const ogUrl = new URL(`${siteConfig.site_domain}/api/og`); 32 | ogUrl.searchParams.append("title", page.title.rendered); 33 | // Strip HTML tags for description and limit length 34 | const description = page.excerpt?.rendered 35 | ? page.excerpt.rendered.replace(/<[^>]*>/g, "").trim() 36 | : page.content.rendered 37 | .replace(/<[^>]*>/g, "") 38 | .trim() 39 | .slice(0, 200) + "..."; 40 | ogUrl.searchParams.append("description", description); 41 | 42 | return { 43 | title: page.title.rendered, 44 | description: description, 45 | openGraph: { 46 | title: page.title.rendered, 47 | description: description, 48 | type: "article", 49 | url: `${siteConfig.site_domain}/pages/${page.slug}`, 50 | images: [ 51 | { 52 | url: ogUrl.toString(), 53 | width: 1200, 54 | height: 630, 55 | alt: page.title.rendered, 56 | }, 57 | ], 58 | }, 59 | twitter: { 60 | card: "summary_large_image", 61 | title: page.title.rendered, 62 | description: description, 63 | images: [ogUrl.toString()], 64 | }, 65 | }; 66 | } 67 | 68 | export default async function Page({ 69 | params, 70 | }: { 71 | params: Promise<{ slug: string }>; 72 | }) { 73 | const { slug } = await params; 74 | const page = await getPageBySlug(slug); 75 | 76 | if (!page) { 77 | notFound(); 78 | } 79 | 80 | return ( 81 |
82 | 83 | 84 |

{page.title.rendered}

85 |
86 | 87 | 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /components/posts/post-card.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { Post } from "@/lib/wordpress.d"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | import { 8 | getFeaturedMediaById, 9 | getAuthorById, 10 | getCategoryById, 11 | } from "@/lib/wordpress"; 12 | 13 | export async function PostCard({ post }: { post: Post }) { 14 | const media = post.featured_media 15 | ? await getFeaturedMediaById(post.featured_media) 16 | : null; 17 | const author = post.author ? await getAuthorById(post.author) : null; 18 | const date = new Date(post.date).toLocaleDateString("en-US", { 19 | month: "long", 20 | day: "numeric", 21 | year: "numeric", 22 | }); 23 | const category = post.categories?.[0] 24 | ? await getCategoryById(post.categories[0]) 25 | : null; 26 | 27 | return ( 28 | 35 |
36 |
37 | {media?.source_url ? ( 38 | {post.title?.rendered 45 | ) : ( 46 |
47 | No image available 48 |
49 | )} 50 |
51 |
57 |
66 |
67 | 68 |
69 |
70 |
71 |

{category?.name || "Uncategorized"}

72 |

{date}

73 |
74 |
75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /plugin/next-revalidate/README.txt: -------------------------------------------------------------------------------- 1 | === Next.js Revalidation === 2 | Contributors: 9d8 3 | Tags: next.js, headless, revalidation, cache 4 | Requires at least: 5.0 5 | Tested up to: 6.4 6 | Stable tag: 1.0.1 7 | Requires PHP: 7.2 8 | License: GPLv2 or later 9 | License URI: https://www.gnu.org/licenses/gpl-2.0.html 10 | 11 | Automatically revalidate your Next.js site when WordPress content changes. 12 | 13 | == Description == 14 | 15 | Next.js Revalidation is a WordPress plugin designed to work with the `next-wp` Next.js starter template. It triggers revalidation of your Next.js site's cache whenever content is added, updated, or deleted in WordPress. 16 | 17 | The plugin sends webhooks to your Next.js site's revalidation API endpoint, ensuring your headless frontend always displays the most up-to-date content. 18 | 19 | **Key Features:** 20 | 21 | * Automatic revalidation when posts, pages, categories, tags, authors, or media are modified 22 | * Settings page to configure your Next.js site URL and webhook secret 23 | * Manual revalidation option for full site refresh 24 | * Support for custom post types and taxonomies 25 | * Optional admin notifications for revalidation events 26 | 27 | == Installation == 28 | 29 | 1. Upload the `next-revalidate` folder to the `/wp-content/plugins/` directory 30 | 2. Activate the plugin through the 'Plugins' menu in WordPress 31 | 3. Go to Settings > Next.js Revalidation to configure your Next.js site URL and webhook secret 32 | 33 | == Configuration == 34 | 35 | 1. Visit Settings > Next.js Revalidation in your WordPress admin 36 | 2. Enter your Next.js site URL without a trailing slash (e.g., https://your-site.com) 37 | 3. Enter the webhook secret which should match the WORDPRESS_WEBHOOK_SECRET in your Next.js environment 38 | 4. Optionally enable admin notifications for revalidation events 39 | 5. Click "Save Settings" 40 | 41 | == Frequently Asked Questions == 42 | 43 | = What is the webhook secret for? = 44 | 45 | The webhook secret provides security for your revalidation API endpoint. It ensures that only your WordPress site can trigger revalidations. 46 | 47 | = How do I set up my Next.js site for revalidation? = 48 | 49 | Your Next.js site needs an API endpoint at `/api/revalidate` that can process the webhook payloads from this plugin. 50 | See the README in your Next.js project for more details. 51 | 52 | = Does this work with custom post types? = 53 | 54 | Yes, the plugin automatically detects and handles revalidation for custom post types and taxonomies. 55 | 56 | == Changelog == 57 | 58 | = 1.0.1 = 59 | * Fix: Register AJAX actions for manual revalidation 60 | * Fix: Normalize Next.js site URL in settings (remove trailing slash) 61 | = 1.0.0 = 62 | * Initial release 63 | 64 | == Upgrade Notice == 65 | 66 | = 1.0.0 = 67 | Initial release of the Next.js Revalidation plugin. -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build Commands 6 | - `pnpm dev` - Start development server with turbo mode 7 | - `pnpm build` - Build for production 8 | - `pnpm start` - Start production server 9 | - `pnpm lint` - Run ESLint 10 | 11 | ## Architecture Overview 12 | 13 | Headless WordPress starter using Next.js 16 App Router with TypeScript. 14 | 15 | ### Data Layer (`lib/wordpress.ts`) 16 | - All WordPress REST API interactions centralized here 17 | - Type definitions in `lib/wordpress.d.ts` (Post, Page, Category, Tag, Author, FeaturedMedia) 18 | - `WordPressAPIError` class for consistent error handling 19 | - Cache tags for granular revalidation: `['wordpress', 'posts', 'post-{id}', 'posts-page-{n}']` 20 | - Pagination via `getPostsPaginated()` returns `{ data, headers: { total, totalPages } }` 21 | - Default cache: 1 hour (`revalidate: 3600`) 22 | 23 | ### Routing 24 | - Dynamic: `/posts/[slug]`, `/pages/[slug]` 25 | - Archives: `/posts`, `/posts/authors`, `/posts/categories`, `/posts/tags` 26 | - API: `/api/revalidate` (webhook), `/api/og` (OG images) 27 | 28 | ### Data Fetching Patterns 29 | - Server Components with parallel `Promise.all()` calls 30 | - `generateStaticParams()` uses `getAllPostSlugs()` for static generation 31 | - URL-based state for search/filters via `searchParams` 32 | - Debounced search (300ms) in `SearchInput` component 33 | 34 | ### Revalidation Flow 35 | 1. WordPress plugin sends webhook to `/api/revalidate` 36 | 2. Validates `x-webhook-secret` header against `WORDPRESS_WEBHOOK_SECRET` 37 | 3. Calls `revalidateTag()` for specific content types (posts, categories, tags, authors) 38 | 39 | ### Configuration Files 40 | - `site.config.ts` - Site metadata (domain, name, description) 41 | - `menu.config.ts` - Navigation menu structure 42 | - `next.config.ts` - Image remotePatterns, /admin redirect to WordPress 43 | 44 | ## Code Style 45 | 46 | ### TypeScript 47 | - Strict typing with interfaces from `lib/wordpress.d.ts` 48 | - Async params: `params: Promise<{ slug: string }>` (Next.js 15+ pattern) 49 | 50 | ### Naming 51 | - Components: PascalCase (`PostCard.tsx`) 52 | - Functions/variables: camelCase 53 | - Types/interfaces: PascalCase 54 | 55 | ### File Structure 56 | - Pages: `/app/**/*.tsx` 57 | - UI components: `/components/ui/*.tsx` (shadcn/ui) 58 | - Feature components: `/components/posts/*.tsx`, `/components/theme/*.tsx` 59 | - WordPress functions must include cache tags 60 | 61 | ## Environment Variables 62 | ``` 63 | WORDPRESS_URL="https://example.com" # Full WordPress URL 64 | WORDPRESS_HOSTNAME="example.com" # For Next.js image optimization 65 | WORDPRESS_WEBHOOK_SECRET="secret-key" # Webhook validation 66 | ``` 67 | 68 | ## Key Dependencies 69 | - Next.js 16 with React 19 70 | - Tailwind CSS v4 with `@tailwindcss/postcss` 71 | - shadcn/ui components (Radix primitives) 72 | - brijr/craft for layout (`Section`, `Container`, `Article`, `Prose`) 73 | -------------------------------------------------------------------------------- /components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { ButtonProps, buttonVariants } from "@/components/ui/button" 6 | 7 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( 8 |