including language switcher and live editing
- App Router setup with i18n routing β Internationalization (i18n) for Next.js
- Follow the 8 steps from the next-intl documentation adapted to the existing Tina CMS code
- Implement i18n support for pages, blog posts, navigation and locale switcher
pnpm install next-intl
- Translations for the NotFound page
{
"NotFound": {
"title": "Seite nicht gefunden",
"description": "Verloren, diese Seite ist. In einem anderen System kΓΆnnte sie sein.",
"link": "ZurΓΌck zur Startseite"
}
}
- Decouple TinaCMS config from Next.js config dependency to prevent errors during TinaCMS build
- Enable next-intl plugin wrapper in next.config.ts without compatibility issues
import createNextIntlPlugin from 'next-intl/plugin';
...
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
- Remove
import nextConfig from '../next.config'
to resolve conflicts - Hardcode basePath as empty string
...
outputFolder: "admin", // within the public folder
basePath: "", // Hardcoded - was always empty anyway! Changed due to error with next-intl.
app / [locale] / layout.tsx;
app / [locale] / page.tsx;
app / [locale] / not - found.tsx;
app / [locale] / [...urlSegments] / page.tsx;
app / [locale] / [...urlSegments] / client - page.tsx;
app / [locale] / posts / page.tsx;
app / [locale] / posts / client - page.tsx;
app / [locale] / posts / [...urlSegments] / client - page.tsx;
app / [locale] / posts / [...urlSegments] / page.tsx;
- No changes
- To share the configuration between navigation and middleware
- Created routing configuration to define supported locales and default locale.
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
// A list of all locales that are supported
locales: ["en", "de"],
// Used when no locale matches
defaultLocale: "en",
});
- No changes
- Added navigation utilities to facilitate locale-aware navigation.
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
// Lightweight wrappers around Next.js' navigation
// APIs that consider the routing configuration
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);
- Add
admin
to the exclusions
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
// Match all pathnames except for
// - β¦ if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
// - β¦ the ones containing a dot (e.g. `favicon.ico`)
// - β¦ `/admin` paths (for Tina CMS)
matcher: "/((?!api|trpc|_next|_vercel|admin|.*\\..*).*)",
};
- No changes
- Used to provide messages based on the userβs locale
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
// Typically corresponds to the `[locale]` segment
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});
- Updated RootLayout to validate incoming locale
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
...
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
// Ensure that the incoming `locale` is valid
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
return (
<html
lang={locale}
className={cn(fontSans.variable, nunito.variable, lato.variable)}
>
<body className="min-h-screen bg-background font-sans antialiased">
<VideoDialogProvider>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<VideoDialog />
</VideoDialogProvider>
- Modified Home and Page components to support locale-specific content retrieval with fallback mechanisms.
app/[locale]/page.tsx
export default async function Home({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Try locale-specific home first, fallback to generic home
let data;
try {
data = await client.queries.page({
relativePath: `${locale}/home.mdx`,
});
} catch (error) {
// Fallback to non-locale specific home
try {
data = await client.queries.page({
relativePath: `home.mdx`,
});
10000
} catch (fallbackError) {
throw error; // Re-throw original error
}
}
- Enhanced NotFound component to utilize translations for dynamic content.
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
...
export default function NotFound() {
const t = useTranslations("NotFound");
...
<h1 className="mt-4 text-balance text-5xl font-semibold tracking-tight text-primary sm:text-7xl">
{t("title")}
</h1>
<p className="mt-6 text-pretty text-lg font-medium text-muted-foreground sm:text-xl/8">
{t("description")}
</p>
<div className="mt-10 mx-auto">
<Button asChild>
<Link href="/">{t("link")}</Link>
- Integrated locale handling in URL segments.
import { hasLocale } from 'next-intl';Add commentMore actions
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
...
}: {
params: Promise<{ locale: string; urlSegments: string[] }>;
}) {
const { locale, urlSegments } = await params;
// Validate locale
if (!hasLocale(routing.locales, locale)) {
notFound();
}
// Enable static rendering
setRequestLocale(locale);
const filepath = urlSegments.join('/');
let data;
try {
// Try locale-specific content first
data = await client.queries.page({
relativePath: `${locale}/${filepath}.mdx`,
});
} catch (error) {
// Fallback to non-locale specific content
try {
data = await client.queries.page({
relativePath: `${filepath}.mdx`,
});
} catch (fallbackError) {
notFound();
}
- content/pages/en/home.mdx
- content/pages/en/about.mdx
- content/pages/de/home.mdx
- content/pages/de/about.mdx
---
blocks:
... (translated content)
content / posts / de / june / learning - about - tinacloud.mdx;
content / posts / de / learning - about - components.mdx;
content / posts / de / learning - about - markdown.mdx;
content / posts / de / learning - about - mermaid.mdx;
content / posts / de / learning - about - tinacms.mdx;
content / posts / de / learning - to - blog.mdx;
content / posts / en / june / learning - about - tinacloud.mdx;
content / posts / en / learning - about - components.mdx;
content / posts / en / learning - about - markdown.mdx;
content / posts / en / learning - about - mermaid.mdx;
content / posts / en / learning - about - tinacms.mdx;
content / posts / en / learning - to - blog.mdx;
- Add server-side filtering in posts page to show only locale-specific content
- Filter posts by checking first breadcrumb segment against current locale
- Filter posts list by locale
import { hasLocale } from 'next-intl';
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
export const revalidate = 300;
export default async function PostsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
...
allPosts.data.postConnection.edges.push(
...posts.data.postConnection.edges.reverse()
);
}
// Filter posts by locale based on the breadcrumbs (first segment is the locale)
const filteredEdges = allPosts.data.postConnection.edges.filter((edge) => {
// Check if the first breadcrumb matches the current locale
return edge?.node?._sys.breadcrumbs[0] === locale;
});
// Create a filtered version of the posts data
const filteredPosts = {
...allPosts,
data: {
...allPosts.data,
postConnection: {
...allPosts.data.postConnection,
edges: filteredEdges,
},
},
};
return (
<Layout rawPageData={filteredPosts.data}>
<PostsClientPage {...filteredPosts} />
</Layout>
...
- Added
breadcrumbsWithoutLocale
to prevent duplicate locale in route such as:domain/de/posts/de/article
...
formattedDate = format(date, 'MMM dd, yyyy');
}
const breadcrumbsWithoutLocale = post._sys.breadcrumbs.slice(1);
...
tags: post.tags?.map((tag) => tag?.tag?.name) || [],
url: `/posts/${breadcrumbsWithoutLocale.join('/')}`,
...
- Update individual post page to handle locale parameter properly
- Filepath with locale before article name:
content/posts/de/learning-to-blog.mdx
...
import { hasLocale } from 'next-intl';
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
...
params: Promise<{ locale: string; urlSegments: string[] }>;
}) {
const resolvedParams = await params;
const { locale, urlSegments } = resolvedParams;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
const filepath = `${locale}/${urlSegments.join('/')}`;
let data;
try {
data = await client.queries.post({
relativePath: `${filepath}.mdx`,
});
} catch (error) {
notFound();
}
...
urlSegments: edge?.node?._sys.breadcrumbs.slice(1),
- Add internationalization support to Layout component with menu items sourced from
global/index.json
- Translate and move files into
content/global/de/index.json
andcontent/global/en/index.json
- Import getLocale from next-intl/server to detect current locale
- Implement try-catch pattern to load locale-specific global content first
- Add fallback to non-locale specific content
...
import { getLocale } from 'next-intl/server';
...
// Get the current localeAdd commentMore actions
const locale = await getLocale();
let globalData;
try {
// Try locale-specific global content first
globalData = await client.queries.global(
{
relativePath: `${locale}/index.json`,
},
{
fetchOptions: {
next: {
revalidate: 60,
},
},
}
);
} catch (error) {
// Fallback to non-locale specific content
try {
globalData = await client.queries.global(
{
relativePath: 'index.json',
},
{
fetchOptions: {
next: {
revalidate: 60,
},
},
}
);
} catch (fallbackError) {
throw error; // Re-throw original error
}
}
return (
<LayoutProvider
...
- Translated from content/global/en/index.json
"nav": [
{
"href": "/",
"label": "Hauptseite"
},
{
"href": "/about",
"label": "Γber Uns"
},
{
"href": "/posts",
"label": "Das Blog"
}
]
- Add language switcher with flag icons (π©πͺ/πΊπΈ) before site name
- Place language switcher and site name at bottom of mobile menu
- Integrate with existing next-intl setup
- Based on https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/components/
import { useLocale, useTranslations } from "next-intl";
import { routing } from "@/i18n/routing";
import LocaleSwitcherSelect from "./LocaleSwitcherSelect";
export default function LocaleSwitcher() {
const t = useTranslations("LocaleSwitcher");
const locale = useLocale();
return (
<LocaleSwitcherSelect defaultValue={locale} label={t("label")}>
{routing.locales.map((cur) => (
<option key={cur} value={cur}>
{t("locale", { locale: cur })}
</option>
))}
</LocaleSwitcherSelect>
);
}
"use client";
import { useRouter, usePathname } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { ChangeEvent, ReactNode, useTransition } from "react";
type Props = {
children: ReactNode;
defaultValue: string;
label: string;
};
export default function LocaleSwitcherSelect({
children,
defaultValue,
label,
}: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
const params = useParams();
function onSelectChange(event: ChangeEvent<HTMLSelectElement>) {
const nextLocale = event.target.value;
startTransition(() => {
router.replace(
// @ts-expect-error
{ pathname, params },
{ locale: nextLocale }
);
});
}
return (
<label className="relative text-sm text-muted-foreground">
<p className="sr-only">{label}</p>
<select
className="inline-flex appearance-none bg-transparent py-2 pl-2 pr-6 outline-none cursor-pointer"
defaultValue={defaultValue}
disabled={isPending}
onChange={onSelectChange}
>
{children}
</select>
<span className="pointer-events-none absolute right-2 top-[8px]">β</span>
</label>
);
}
import LocaleSwitcher from "./LocaleSwitcher";
...
{/* Right side: Language Switcher + Site Name */}
<div className="hidden lg:flex items-center gap-4 h-full">
<LocaleSwitcher />
<span className="text-sm font-medium text-muted-foreground">
|
</span>
<span className="text-sm font-medium">
{header.name}
</span>
</div>
...
{/* Mobile Language Switcher & Site Name */}
<div className="flex items-center justify-between pt-4 border-t">
<LocaleSwitcher />
<span className="text-sm font-medium">
{header.name}
</span>
</div>
"LocaleSwitcher": {
"label": "Sprache Γ€ndern",
"locale": "{locale, select, de {π©πͺ Deutsch} en {πΊπΈ English} other {Unknown}}"
},
"LocaleSwitcher": {
"label": "Change language",
"locale": "{locale, select, de {π©πͺ Deutsch} en {πΊπΈ English} other {Unknown}}"
},
Theoretically this should work without issues:
- https://tina.io/blog/react-19-support
- https://react.dev/blog/2024/04/25/react-19-upgrade-guide
- Codemods are not required
pnpm add tinacms@latest @tinacms/cli@latest
pnpm add react@latest react-dom@latest @types/react@latest @types/react-dom@latest
- This causes numerous dependency warnings, for example:
βWARNβ Issues with peer dependencies found
βββ¬ tinacms 2.7.8
β βββ¬ @tinacms/mdx 1.6.3
β βββ¬ @tinacms/schema-tools 1.7.4
β β βββ β unmet peer yup@^0.32.0: found 1.6.1
β βββ¬ typedoc 0.26.11
β βββ β unmet peer typescript@5.6.3: found 5.8.3
βββ¬ @tinacms/cli 1.9.8
β βββ¬ @tinacms/metrics 1.0.9
β βββ β unmet peer fs-extra@^9.0.1: found 11.3.0
βββ¬ next-intl 4.1.0
βββ β unmet peer typescript@5.6.3: found 5.8.3
- To override dependency versions and ignore React version warnings
- Add this to
pnpm-workspace.yaml
:
"yup": "1.6.1"
"fs-extra": "11.3.0"
"typescript": "5.8.3"
peerDependencyRules:
allowedVersions:
react: "19"
react-dom: "19"
Pin to the latest versions
json{
"dependencies": {
"fs-extra": "^11.3.0",
"yup": "^1.6.1"
}
}
rm -rf node_modules pnpm-lock.yaml
pnpm install
pnpm build
Some packages apparently required the older versions previously, therefore running these with the newer versions may need more testing.
Moving typescript from 5.6.3 to 5.8.3 is only a minor version increase, and is least likely to cause issues.
This Next.js starter is powered by TinaCMS for you and your team to visually live edit the structured content of your website. β¨
The content is managed through Markdown and JSON files stored in your GitHub repository, and queried through Tina GraphQL API.
- Tina Headless CMS for authentication, content modeling, visual editing and team management.
- Vercel deployment to visually edit your site from the
/admin
route. - Local development workflow from the filesystem with a local GraqhQL server.
- Git, Node.js Active LTS, pnpm installed for local development.
- A TinaCMS account for live editing.
Install the project's dependencies:
Note
Do you know the best package manager for Node.js? Using the right package manager can greatly enhance your development workflow. We recommend using pnpm for its speed and efficient handling of dependencies. Learn more about why pnpm might be the best choice for your projects by checking out this rule from SSW.
pnpm install
Run the project locally:
pnpm dev
- http://localhost:3000 : browse the website
- http://localhost:3000/admin : connect to Tina Cloud and go in edit mode
- http://localhost:3000/exit-admin : log out of Tina Cloud
- http://localhost:4001/altair/ : GraphQL playground to test queries and browse the API documentation
This starter can be deployed to GitHub Pages. A GitHub Actions workflow is included that handles the build and deployment process.
To deploy to GitHub Pages:
- In your repository settings, ensure GitHub Pages is enabled and set to deploy from the
gh-pages
branch - Push changes to your main branch - the workflow will automatically build and deploy the site
Note
When deploying to GitHub Pages, you'll need to update your secrets in Settings | Secrets and variables | Actions to include:
NEXT_PUBLIC_TINA_CLIENT_ID
TINA_TOKEN
You get these from your TinaCloud project - read the docs
Important
GitHub Pages does not support server side code, so this will run as a static site. If you don't want to deploy to GitHub pages, just delete .github/workflows/build-and-deploy.yml
Replace the .env.example
, with .env
NEXT_PUBLIC_TINA_CLIENT_ID=<get this from the project you create at app.tina.io>
TINA_TOKEN=<get this from the project you create at app.tina.io>
NEXT_PUBLIC_TINA_BRANCH=<Specify the branch with Tina configured>
Build the project:
pnpm build
To get help with any TinaCMS challenges you may have:
- Visit the documentation to learn about Tina.
- Join our Discord to share feedback.
- Visit the community forum to ask questions.
- Get support through the chat widget on the TinaCMS Dashboard
- Email us to schedule a call with our team and share more about your context and what you're trying to achieve.
- Search or open an issue if something is not working.
- Reach out on Twitter at @tina_cms.
Install the GraphQL extension to benefit from type auto-completion.
A good way to ensure your components match the shape of your data is to leverage the auto-generated TypeScript types.
These are rebuilt when your tina
config changes.
Licensed under the Apache 2.0 license.