🥹Hãy triển khai sử dụng đã ngôn ngữ nextjs, wordpress sử dụng plugin polylang

1. Triển khai đã ngôn ngữ cho bài viết

package.json

{
  "name": "langw",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@apollo/client": "^3.13.8",
    "graphql": "^16.11.0",
    "next": "15.3.2",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

.env

WORDPRESS_API_URL=https://test1.com/graphql
NODE_TLS_REJECT_UNAUTHORIZED=0
# Only required if you want to enable preview mode
# WORDPRESS_AUTH_REFRESH_TOKEN=
# WORDPRESS_PREVIEW_SECRET=

src\lib\apollo.ts

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'
export const createApolloClient = (locale: string) => {
  return new ApolloClient({
    link: new HttpLink({
      uri: 'https://test1.com/graphql',
      headers: {
        'Accept-Language': locale,
      },
    }),
    cache: new InMemoryCache(),
  })
}

middleware.ts

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const locales = ['en', 'vi']
export function middleware(req: NextRequest) {
  const pathname = req.nextUrl.pathname
  const pathnameIsMissingLocale = locales.every(
    (locale) => !pathname.startsWith(`/${locale}`),
  )
  if (pathnameIsMissingLocale) {
    const defaultLocale = req.headers.get('accept-language')?.startsWith('vi') ? 'vi' : 'en'
    return NextResponse.redirect(new URL(`/${defaultLocale}${pathname}`, req.url))
  }
  return NextResponse.next()
}

src\graphql\queries.ts

import { gql } from '@apollo/client'
export const GET_HOMEPAGE = gql`
  query {
    page(id: "home", idType: URI) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
    }
  }
`
export const GET_POST = gql`
  query GetPost($slug: String!) {
    postBy(slug: $slug) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
      language {
        code
      }
    }
  }
`
export const GET_PAGE = gql`
  query GetPage($slug: String!) {
    pageBy(uri: $slug) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
      language {
        code
      }
    }
  }
`;
query {
  pageBy(uri: "test") {
    title
    content
      translations {
      uri
      language {
        code
      }
    }
    language {
      code
    }
  }
}
{
  "data": {
    "pageBy": {
      "title": "Test",
      "content": "<p>Test</p>\n",
      "translations": [
        {
          "uri": "/vi/kiem-tra/",
          "language": {
            "code": "VI"
          }
        }
      ],
      "language": {
        "code": "EN"
      }
    }
  }
}

src\components\LanguageSwitcher.tsx

"use client";
import { usePathname, useRouter } from "next/navigation";
type Translation = {
  uri: string;
  language: {
    code: string;
  };
};
type Props = {
  currentLang: string;
  translations: Translation[];
};
const supportedLocales = ["en", "vi"];
export default function LanguageSwitcher({ currentLang, translations }: Props) {
  const router = useRouter();
  const pathname = usePathname();
  console.log("LanguageSwitcher Translations:", translations);
  const handleSwitch = (lang: string) => {
    if (lang === currentLang) return;
    const translation = translations.find(
      (t) => t.language.code.toLowerCase() === lang
    );
    console.log("Target lang:", lang, "Found translation:", translation);
    if (translation) {
      let uri = translation.uri;
      // Handle homepage: uri is /lang or /lang/ -> navigate to /lang
      if (uri === `/${lang}` || uri === `/${lang}/`) {
        console.log("Navigating to homepage:", `/${lang}`);
        router.push(`/${lang}`);
        return;
      }
      // For non-homepage, uri is already normalized (e.g., /vi/that-bai-la-me-thanh-cong)
      console.log("Navigating to:", uri);
      router.push(uri);
    } else {
      // Fallback: Replace the language prefix in the current pathname
      const newPath = pathname.replace(/^\/[^\/]+/, `/${lang}`);
      console.log("Fallback URL:", newPath);
      router.push(newPath);
    }
  };
  return (
    <div className="flex gap-2 text-sm">
      {supportedLocales.map((lang) => (
        <button
          key={lang}
          onClick={() => handleSwitch(lang)}
          className={`px-2 py-1 rounded ${
            lang === currentLang ? "bg-blue-600 text-white" : "bg-gray-200"
          }`}
        >
          {lang.toUpperCase()}
        </button>
      ))}
    </div>
  );
}

Home Page

src\app\[lang]\page.tsx

import { createApolloClient } from "@/lib/apollo";
import { GET_PAGE } from "@/graphql/queries";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { supportedLocales } from "./layout";
export default async function Home({ params }: { params: { lang: string } }) {
  const { lang } = await params;
  // Map lang to CMS homepage slug
  const slugMap: { [key: string]: string } = {
    en: "home",
    vi: "trang-chu",
  };
  const pageSlug = slugMap[lang] || lang; // Fallback to lang if slug not found
  const client = createApolloClient(lang);
  const { data } = await client.query({
    query: GET_PAGE,
    variables: { 
      slug: pageSlug
    },
  });
  // Normalize translations: always use /lang for homepage
  const translations = supportedLocales.map((locale) => ({
    uri: `/${locale}`,
    language: { code: locale.toUpperCase() },
  }));
  return (
    <main>
      <LanguageSwitcher currentLang={lang} translations={translations} />
      <h1>{data.pageBy.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.pageBy.content }} />
    </main>
  );
}

Layout

src\app\[lang]\layout.tsx

import { Metadata } from "next";
export const supportedLocales = ["en", "vi"] as const; // Use 'as const' for literal types
export function generateStaticParams() {
  return supportedLocales.map((lang) => ({ lang }));
}
export async function generateMetadata({ params }: { params: { lang: string }; }): Promise<Metadata> {
  const { lang } = await params;
  return {
    title: lang === "vi" ? "Ứng dụng Next" : "Next App",
    description: lang === "vi" ? "Được tạo bởi create next app" : "Generated by create next app",
  };
}
async function LangLayout({ children, params}: { children: React.ReactNode; params: { lang: string }; }) {
  const { lang } = await params;
  return (
    <>
      <header>
        <nav>
          <a href={`/${lang}`}>Home</a>
        </nav>
      </header>
      {children}
    </>
  );
}
export default LangLayout;

Page Detail

src\app\[lang]\[slug]\page.tsx

import { createApolloClient } from "@/lib/apollo";
import { GET_PAGE } from "@/graphql/queries";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { supportedLocales } from "../layout";
export default async function Page({ params }: { params: { lang: string; slug: string } }) {
  const { lang, slug } = await params;
  const client = createApolloClient(lang);
  const { data } = await client.query({
    query: GET_PAGE,
    variables: { 
      slug
    },
  });
  console.log("Page Translations:", data);
  // console.log("Page Language:", data.pageBy.language.code);
  // Normalize translations: use /lang/[slug] for non-homepage
  const translations = supportedLocales.map((locale) => {
    const cmsTranslation = Array.isArray(data.pageBy.translations)
      ? data.pageBy.translations.find((t: any) => t.language.code.toLowerCase() === locale)
      : null;
    if (cmsTranslation) {
      let uri = cmsTranslation.uri;
      const langPrefix = `/${locale}/`;
      if (uri.startsWith(langPrefix)) {
        uri = uri.replace(langPrefix, "/");
      }
      uri = uri.endsWith("/") ? uri.slice(0, -1) : uri;
      return {
        uri: `/${locale}${uri}`,
        language: { code: locale.toUpperCase() },
      };
    }
    return {
      uri: `/${locale}/${slug}`,
      language: { code: locale.toUpperCase() },
    };
  });
  console.log("Normalized Translations:", translations);
  return (
    <main>
      <LanguageSwitcher currentLang={lang} translations={translations} />
      <h1>{data.pageBy.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.pageBy.content }} />
    </main>
  );
}
export async function generateStaticParams() {
  const slugsByLang = {
    en: ["test"],
    vi: ["kiem-tra"],
  };
  return supportedLocales.flatMap((lang) =>
    slugsByLang[lang].map((slug) => ({ lang, slug }))
  );
}

Post Details

src\app\[lang]\post\[slug]\page.tsx

import { createApolloClient } from '@/lib/apollo';
import { GET_POST } from '@/graphql/queries';
import LanguageSwitcher from '@/components/LanguageSwitcher'
export async function generateStaticParams() {
  return []
}
export default async function PostPage({ params }: { params: { lang: string; slug: string } }) {
  const { lang, slug } = await params;
  const client = createApolloClient(lang)
  const { data } = await client.query({
    query: GET_POST,
    variables: { slug }
  });
  const post = data.postBy;
  const updatedData = { ...post };
  updatedData.translations = updatedData.translations.map((translation:any) => ({
    ...translation,
    uri: translation.uri.replace(/\/([^/]+)\/$/, '/post/$1/')
  }));
  return (
    <main>
      <LanguageSwitcher currentLang={lang} translations={updatedData.translations} />
      <h1>{updatedData.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: updatedData.content }} />
      <hr />
      <ul>
        {updatedData.translations.map((trans: any) => (
          <li key={trans.language.code}>
            <a href={`/${trans.language.code}${trans.uri}`}>
              {trans.language.code.toUpperCase()}
            </a>
          </li>
        ))}
      </ul>
    </main>
  )
}

Show Category In Home Page

src\app\[lang]\page.tsx

import { createApolloClient } from "@/lib/apollo";
import { GET_PAGE,GET_CATEGORIES } from "@/graphql/queries";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { supportedLocales } from "./layout";
import Link from "next/link";
export default async function Home({ params }: { params: { lang: string } }) {
  const { lang } = await params;
  // Map lang to CMS homepage slug
  const slugMap: { [key: string]: string } = {
    en: "home",
    vi: "trang-chu",
  };
  const pageSlug = slugMap[lang] || lang; // Fallback to lang if slug not found
  const client = createApolloClient(lang);
  const { data } = await client.query({
    query: GET_PAGE,
    variables: { 
      slug: pageSlug
    },
  });
  // Fetch categories
  const { data: categoriesData } = await client.query({
    query: GET_CATEGORIES,
  });
  // Filter categories by current language
  const categories = categoriesData.categories.nodes.filter(
    (category: any) => category.language.code.toLowerCase() === lang
  );
  // Normalize translations: always use /lang for homepage
  const translations = supportedLocales.map((locale) => ({
    uri: `/${locale}`,
    language: { code: locale.toUpperCase() },
  }));
  return (
    <main>
      <LanguageSwitcher currentLang={lang} translations={translations} />
      <h1>{data.pageBy.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.pageBy.content }} />
      <h2>Categories</h2>
      <ul>
        {categories.map((category: any) => (
          <li key={category.slug}>
            <Link href={`/${lang}/category/${category.slug}`}>
              {category.name}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Get Posts By Category

src\app[lang]\category\[categorySlug]\page.tsx

import { createApolloClient } from "@/lib/apollo";
import { GET_CATEGORIES, GET_POSTS_BY_CATEGORY } from "@/graphql/queries";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { supportedLocales } from "../../layout";
import Link from "next/link";
import { notFound } from "next/navigation";
export default async function CategoryPage({
  params,
}: {
  params: { lang: string; categorySlug?: string };
}) {
  const { lang, categorySlug } = await params;
  console.log("Category Page Params:", { lang, categorySlug });
  // Validate categorySlug
  if (!categorySlug || typeof categorySlug !== "string") {
    console.error("Invalid categorySlug:", categorySlug);
    notFound();
  }
  const client = createApolloClient(lang);
  const { data } = await client.query({
    query: GET_POSTS_BY_CATEGORY,
    variables: { slug: categorySlug },
  });
  // Handle missing category
  if (!data.category) {
    console.warn(`Category not found for slug: ${categorySlug}`);
    notFound();
  }
  console.log("Category Data:", {
    slug: data.category.slug,
    name: data.category.name,
    language: data.category.language.code,
    translations: data.category.translations,
  });
  // Normalize translations: use /lang/category/[category-slug]
  const translations = supportedLocales.map((locale) => {
    const cmsTranslation = Array.isArray(data.category.translations)
      ? data.category.translations.find((t: any) => t.language.code.toLowerCase() === locale)
      : null;
    if (cmsTranslation) {
      return {
        uri: `/${locale}/category/${cmsTranslation.slug}`,
        language: { code: locale.toUpperCase() },
      };
    }
    // Fallback: use current category slug
    return {
      uri: `/${locale}/category/${categorySlug}`,
      language: { code: locale.toUpperCase() },
    };
  });
  console.log("Normalized Translations:", translations);
  return (
    <main>
      <LanguageSwitcher currentLang={lang} translations={translations} />
      <h1>{data.category.name}</h1>
      <ul>
        {data.category.posts.nodes.length > 0 ? (
          data.category.posts.nodes.map((post: any) => (
            <li key={post.slug}>
              <Link href={`/${lang}/${post.slug}`}>{post.title}</Link>
            </li>
          ))
        ) : (
          <li>No posts found in this category.</li>
        )}
      </ul>
    </main>
  );
}
export async function generateStaticParams() {
  const client = createApolloClient("en");
  const { data } = await client.query({ query: GET_CATEGORIES });
  console.log("CMS Categories:", data.categories.nodes);
  const slugsByLang: { [key: string]: string[] } = { en: [], vi: [] };
  data.categories.nodes.forEach((category: any) => {
    const lang = category.language.code.toLowerCase();
    const slug = category.slug?.trim();
    if (slugsByLang[lang] && slug && slug !== "uncategorized") {
      slugsByLang[lang].push(slug);
    }
  });
  console.log("Slugs by Language:", slugsByLang);
  return supportedLocales.flatMap((lang) =>
    slugsByLang[lang].map((categorySlug) => ({ lang, categorySlug }))
  );
}

src\graphql\queries.ts

import { gql } from '@apollo/client'
export const GET_HOMEPAGE = gql`
  query {
    page(id: "home", idType: URI) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
    }
  }
`
export const GET_POST = gql`
  query GetPost($slug: String!) {
    postBy(slug: $slug) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
      language {
        code
      }
    }
  }
`
export const GET_PAGE = gql`
  query GetPage($slug: String!) {
    pageBy(uri: $slug) {
      title
      content
      translations {
        uri
        language {
          code
        }
      }
      language {
        code
      }
    }
  }
`;
// Query để lấy danh sách chuyên mục
export const GET_CATEGORIES = gql`
  query GetCategories {
    categories {
      nodes {
        slug
        name
        language {
          code
        }
        translations {
          slug
          language {
            code
          }
        }
      }
    }
  }
`;
// Query để lấy bài viết theo chuyên mục
export const GET_POSTS_BY_CATEGORY = gql`
  query GetPostsByCategory($slug: ID!) {
    category(id:$slug, idType: SLUG) {
      slug
      name
      language {
        code
      }
      translations {
        slug
        language {
          code
        }
      }
      posts {
        nodes {
          slug
          title
          content
          language {
            code
          }
          translations {
            slug
            language {
              code
            }
          }
        }
      }
    }
  }
`;

Tiếp tục triển khai gói i18next

Cài đặt các gói cần thiết

npm install i18next react-i18next i18next-http-backend i18next-browser-languagedetector i18next-resources-to-backend

Để triển khai một website sử dụng Next.js làm frontend và WordPress (với plugin Polylang) làm headless CMS hỗ trợ đa ngôn ngữ, ta cần:


🧩 Công nghệ sử dụng:

  • Next.js (App Router hoặc Pages Router tùy chọn)

  • WordPress với plugin Polylang

  • WPGraphQL (để truy vấn dữ liệu)

  • WPGraphQL Polylang (kết nối Polylang với WPGraphQL)

  • Apollo Client hoặc graphql-request trên Next.js để fetch dữ liệu


✅ Cài đặt bên WordPress

  1. Cài Plugin bắt buộc:

    • WPGraphQL

    • Polylang

    • WPGraphQL for Polylang

  2. Tạo nội dung đa ngôn ngữ:

    • Vào Polylang → chọn ngôn ngữ như vi, en

    • Khi viết post/page/custom post type, tạo phiên bản cho mỗi ngôn ngữ

  3. Cấu hình Permalink:

    • Chọn kiểu hiển thị ngôn ngữ (ví dụ: /en/slug, /vi/slug)


🚀 Next.js – Thiết lập fetch dữ liệu đa ngôn ngữ

1. Cài thư viện

npm install @apollo/client graphql

2. Cấu hình Apollo Client

tsCopyEdit// lib/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'

export const createApolloClient = (locale: string) => {
  return new ApolloClient({
    link: new HttpLink({
      uri: 'https://your-wp-site.com/graphql',
      headers: {
        'Accept-Language': locale,
      },
    }),
    cache: new InMemoryCache(),
  })
}

3. Truy vấn GraphQL có đa ngôn ngữ

graphqlCopyEditquery GetPost($slug: String!) {
  postBy(slug: $slug) {
    title
    content
    language {
      code
      locale
    }
    translations {
      uri
      language {
        code
      }
    }
  }
}

🌐 Next.js – Cấu trúc thư mục đa ngôn ngữ

Với App Router, dùng cấu trúc như sau:

bashCopyEdit/app
  /[lang]
    /page.tsx
    /post
      /[slug]
        /page.tsx

Ví dụ app/[lang]/post/[slug]/page.tsx

import { createApolloClient } from '@/lib/apollo'

export async function generateStaticParams() {
  // Fetch slugs in all languages
  return [
    { lang: 'vi', slug: 'bai-viet-1' },
    { lang: 'en', slug: 'post-1' },
  ]
}

export default async function PostPage({ params }: { params: { lang: string; slug: string } }) {
  const client = createApolloClient(params.lang)

  const { data } = await client.query({
    query: POST_QUERY,
    variables: { slug: params.slug },
  })

  return (
    <main>
      <h1>{data.postBy.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.postBy.content }} />
    </main>
  )
}

🌍 Chuyển đổi ngôn ngữ

Bạn có thể tạo một Language Switcher như sau:

tsxCopyEditconst LanguageSwitcher = ({ translations }: { translations: any[] }) => {
  return (
    <ul>
      {translations.map((trans) => (
        <li key={trans.language.code}>
          <a href={`/${trans.language.code}${trans.uri}`}>{trans.language.code.toUpperCase()}</a>
        </li>
      ))}
    </ul>
  )
}

📌 Gợi ý nâng cao

  • Dùng middleware để detect locale từ URL hoặc trình duyệt

  • Kết hợp với next-intl nếu muốn quản lý text tĩnh dễ hơn

  • Tạo layout đa ngôn ngữ: app/[lang]/layout.tsx → giúp inject metadata theo locale


Bạn muốn mình giúp dựng sẵn một template đơn giản không? Hoặc bạn đang dùng App Router hay Pages Router để mình gửi đúng kiểu cấu trúc nhé.

Tuyệt! Dưới đây là cách tạo layout đa ngôn ngữ trong App Router (Next.js 13/14) khi bạn dùng WordPress + Polylang + WPGraphQL, với thư mục như sau:

🧩 Cấu trúc thư mục app/

bashCopyEdit/app
  /[lang]               // locale dynamic route
    layout.tsx          // shared layout per language
    page.tsx            // homepage theo ngôn ngữ
    /post
      /[slug]
        page.tsx        // chi tiết bài viết theo ngôn ngữ

📁 app/[lang]/layout.tsx – Layout đa ngôn ngữ

tsxCopyEdit// app/[lang]/layout.tsx
import { ReactNode } from 'react'
import { notFound } from 'next/navigation'

const supportedLocales = ['en', 'vi']

export function generateStaticParams() {
  return supportedLocales.map((lang) => ({ lang }))
}

export default function LangLayout({
  children,
  params,
}: {
  children: ReactNode
  params: { lang: string }
}) {
  const { lang } = params

  if (!supportedLocales.includes(lang)) {
    notFound()
  }

  return (
    <html lang={lang}>
      <body>
        <header>
          <nav>
            <a href={`/${lang}`}>Home</a> | <a href={`/${lang}/post/sample-post`}>Sample Post</a>
          </nav>
        </header>
        {children}
      </body>
    </html>
  )
}

🏠 app/[lang]/page.tsx – Trang chủ theo ngôn ngữ

tsxCopyEdit// app/[lang]/page.tsx
import { createApolloClient } from '@/lib/apollo'
import { gql } from '@apollo/client'

const HOMEPAGE_QUERY = gql`
  query GetHomepage {
    page(id: "home", idType: URI) {
      title
      content
    }
  }
`

export default async function HomePage({ params }: { params: { lang: string } }) {
  const client = createApolloClient(params.lang)
  const { data } = await client.query({ query: HOMEPAGE_QUERY })

  return (
    <main>
      <h1>{data.page.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.page.content }} />
    </main>
  )
}

🔥 Chú ý: Trang Home ở WordPress phải có slug là home ở cả 2 ngôn ngữ (và đặt làm Front Page).


✨ Tự động chuyển đổi ngôn ngữ (Language Switcher)

Bạn có thể lấy các bản dịch từ WPGraphQL (translations) và render nút chuyển ngôn ngữ:

tsxCopyEditconst LanguageSwitcher = ({ translations }: { translations: any[] }) => (
  <div>
    {translations.map((t) => (
      <a key={t.language.code} href={`/${t.language.code}${t.uri}`}>
        {t.language.code.toUpperCase()}
      </a>
    ))}
  </div>
)

🧠 Middleware (tùy chọn) – Redirect //vi hoặc /en

tsCopyEdit// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const PUBLIC_FILE = /\.(.*)$/
const locales = ['en', 'vi']

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl

  // Bỏ qua file tĩnh
  if (PUBLIC_FILE.test(pathname)) return

  // Nếu chưa có locale → redirect theo browser (hoặc mặc định)
  const pathnameIsMissingLocale = locales.every(
    (locale) => !pathname.startsWith(`/${locale}`),
  )

  if (pathnameIsMissingLocale) {
    const defaultLocale = req.headers.get('accept-language')?.startsWith('vi') ? 'vi' : 'en'
    return NextResponse.redirect(new URL(`/${defaultLocale}${pathname}`, req.url))
  }

  return NextResponse.next()
}

Đừng quên thêm middleware config trong next.config.js nếu cần.

Tuyệt vời! Dưới đây là bản template boilerplate dành cho Next.js (App Router) kết nối với WordPress sử dụng Polylang + WPGraphQL, hỗ trợ đa ngôn ngữ đầy đủ 🎉

🔧 1. Cấu trúc thư mục

bashCopyEdit/app
  /[lang]
    layout.tsx
    page.tsx
    /post
      /[slug]
        page.tsx
/lib
  apollo.ts
/graphql
  queries.ts
/middleware.ts
/next.config.js

📦 2. Cài đặt thư viện

bashCopyEditnpm install @apollo/client graphql

🧠 3. File lib/apollo.ts

tsCopyEditimport { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'

export const createApolloClient = (locale: string) => {
  return new ApolloClient({
    link: new HttpLink({
      uri: 'https://your-wordpress-site.com/graphql',
      headers: {
        'Accept-Language': locale,
      },
    }),
    cache: new InMemoryCache(),
  })
}

📁 4. graphql/queries.ts

tsCopyEditimport { gql } from '@apollo/client'

export const GET_HOMEPAGE = gql`
  query {
    page(id: "home", idType: URI) {
      title
      content
    }
  }
`

export const GET_POST = gql`
  query GetPost($slug: String!) {
    postBy(slug: $slug) {
      title
      content
      language {
        code
      }
      translations {
        uri
        language {
          code
        }
      }
    }
  }
`

🌍 5. app/[lang]/layout.tsx

tsxCopyEditexport const supportedLocales = ['en', 'vi']

export function generateStaticParams() {
  return supportedLocales.map((lang) => ({ lang }))
}

export default function LangLayout({ children, params }: { children: React.ReactNode, params: { lang: string } }) {
  return (
    <html lang={params.lang}>
      <body>
        <header>
          <nav>
            <a href={`/${params.lang}`}>Home</a>
          </nav>
        </header>
        {children}
      </body>
    </html>
  )
}

🏠 6. app/[lang]/page.tsx

tsxCopyEditimport { createApolloClient } from '@/lib/apollo'
import { GET_HOMEPAGE } from '@/graphql/queries'

export default async function Home({ params }: { params: { lang: string } }) {
  const client = createApolloClient(params.lang)
  const { data } = await client.query({ query: GET_HOMEPAGE })

  return (
    <main>
      <h1>{data.page.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.page.content }} />
    </main>
  )
}

📝 7. app/[lang]/post/[slug]/page.tsx

tsxCopyEditimport { createApolloClient } from '@/lib/apollo'
import { GET_POST } from '@/graphql/queries'

export async function generateStaticParams() {
  // TODO: Fetch slugs from WP if muốn pre-render
  return []
}

export default async function PostPage({ params }: { params: { lang: string; slug: string } }) {
  const client = createApolloClient(params.lang)
  const { data } = await client.query({
    query: GET_POST,
    variables: { slug: params.slug },
  })

  const post = data.postBy

  return (
    <main>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <hr />
      <ul>
        {post.translations.map((trans: any) => (
          <li key={trans.language.code}>
            <a href={`/${trans.language.code}${trans.uri}`}>
              {trans.language.code.toUpperCase()}
            </a>
          </li>
        ))}
      </ul>
    </main>
  )
}

🌐 8. middleware.ts

tsCopyEditimport { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const locales = ['en', 'vi']

export function middleware(req: NextRequest) {
  const pathname = req.nextUrl.pathname

  const pathnameIsMissingLocale = locales.every(
    (locale) => !pathname.startsWith(`/${locale}`),
  )

  if (pathnameIsMissingLocale) {
    const defaultLocale = req.headers.get('accept-language')?.startsWith('vi') ? 'vi' : 'en'
    return NextResponse.redirect(new URL(`/${defaultLocale}${pathname}`, req.url))
  }

  return NextResponse.next()
}

⚙️ 9. next.config.js

jsCopyEdit/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
  },
}

module.exports = nextConfig

🚀 Sử dụng

  1. Tạo trang "home" trong WordPress cho cả vien

  2. Cài WPGraphQL, Polylang, WPGraphQL Polylang

  3. Đảm bảo bạn truy cập /vi, /en, /vi/post/ten-bai-viet, ...


👉 Muốn clone nhanh?

Nếu bạn muốn mình tạo repo GitHub public hoặc tạo file .zip để bạn tải về nhanh, mình có thể chuẩn bị ngay. Bạn muốn dạng nào? (GitHub hay .zip?)

4o

You said:

.zip

ChatGPT said:

Mình đã tạo xong file .zip cho boilerplate Next.js + WordPress + Polylang. Bạn có thể tải tại đây:

👉 Tải về dự án .zip


Nếu bạn cần thêm phần:

  • Tự động tạo sitemap theo từng ngôn ngữ

  • Kết nối với WP bằng Auth headers

  • Giao diện Tailwind UI đẹp mắt

Cứ nói nhé, mình hỗ trợ liền! 💪

Dưới đây là bản .zip đã cập nhật với các tính năng bạn yêu cầu:

✅ Sitemap động theo từng ngôn ngữ ✅ Kết nối WordPress sử dụng Authorization Bearer Token ✅ Giao diện đẹp với Tailwind CSS và theme dark mode

👉 Tải về dự án cập nhật (.zip)


Nếu bạn cần:

  • Cấu hình Tailwind nâng cao hơn

  • Hỗ trợ SSR hoặc ISR

  • Tự động tạo sitemap từ bài viết thực tế trong WordPress

Cứ nói nhé, mình làm tiếp cho bạn! 💻✨

Dĩ nhiên! Mình sẽ giúp bạn thêm menu chuyển đổi ngôn ngữ (LanguageSwitcher) dùng Polylang + WPGraphQL. Mục tiêu là:

  • Hiển thị các ngôn ngữ (VD: EN | VI)

  • Biết đang ở ngôn ngữ nào

  • Tự động điều hướng đúng route với slug tương ứng

  • Sử dụng WPGraphQL để lấy translations


🧩 Bước 1: Tạo components/LanguageSwitcher.tsx

tsxCopyEdit'use client'

import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'

type Translation = {
  uri: string
  language: {
    code: string
  }
}

type Props = {
  currentLang: string
  translations: Translation[]
}

const supportedLocales = ['en', 'vi']

export default function LanguageSwitcher({ currentLang, translations }: Props) {
  const router = useRouter()
  const pathname = usePathname()

  const handleSwitch = (lang: string) => {
    if (lang === currentLang) return

    const translation = translations.find(t => t.language.code === lang)

    if (translation) {
      router.push(`/${lang}${translation.uri}`)
    } else {
      // fallback: stay on same path but change lang
      const newPath = pathname.replace(`/${currentLang}`, `/${lang}`)
      router.push(newPath)
    }
  }

  return (
    <div className="flex gap-2 text-sm">
      {supportedLocales.map((lang) => (
        <button
          key={lang}
          onClick={() => handleSwitch(lang)}
          className={`px-2 py-1 rounded ${
            lang === currentLang ? 'bg-blue-600 text-white' : 'bg-gray-200'
          }`}
        >
          {lang.toUpperCase()}
        </button>
      ))}
    </div>
  )
}

🛠 Bước 2: Gọi LanguageSwitcher trong post page

Trong app/[lang]/post/[slug]/page.tsx, sau khi lấy post.translations, thêm:

tsxCopyEditimport LanguageSwitcher from '@/components/LanguageSwitcher'

// ...

return (
  <main>
    <LanguageSwitcher currentLang={params.lang} translations={post.translations} />

    <h1 className="text-2xl font-bold mt-4">{post.title}</h1>
    <div dangerouslySetInnerHTML={{ __html: post.content }} />
  </main>
)

🎁 Tuỳ chọn: Đặt vào layout.tsx nếu muốn luôn hiển thị trên header

Bạn cũng có thể truyền translations qua layout (phức tạp hơn vì phải "hoist") — nếu bạn muốn mình làm phần này thì mình có thể hỗ trợ mở rộng logic layout để chứa LanguageSwitcher ở header trên mọi page.


Mình có thể cập nhật luôn trong project .zip nếu bạn muốn. Bạn muốn:

  1. ✅ Mình cập nhật lại .zip với LanguageSwitcher

  2. ✏️ Bạn tự thêm vào

Bạn chọn cách nào?

4o

You said:

Đặt vào layout.tsx

ChatGPT said:

🎉 Dự án đã được cập nhật với LanguageSwitcher trong layout.tsx và đã sẵn sàng tải về:

👉 Tải về project cuối cùng (.zip)


Tóm tắt thay đổi:

  • Tạo components/LanguageSwitcher.tsx

  • Gọi nó trong layout.tsx, hiển thị trên mọi trang

  • Hỗ trợ chuyển đổi slug hoặc fallback mặc định

  • Giao diện Tailwind đơn giản, có thể tuỳ chỉnh dễ dàng

Nếu bạn muốn lấy translations từ post cụ thể thay vì hardcoded (/), mình có thể giúp bạn tích hợp WPGraphQL theo URL hiện tại.

Muốn mở rộng nữa không? 😄

Last updated

Was this helpful?