-
-
Notifications
You must be signed in to change notification settings - Fork 361
feat: playground #2084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: playground #2084
Changes from all commits
59842d7
5b23c25
0f3f97c
e16db8a
3ce3957
675e533
37201bd
4208d3f
adae65c
41ed301
b4fb489
2542c8a
5a9c8be
5686703
337adbc
ea3b26f
7e0a9eb
b7f2a9a
f44bb0c
3de4122
fa5c90b
c997228
d2da339
91d9da5
55730c5
be923e3
4aac591
77ee713
8d90418
5fad482
c33109c
678d9cf
dc3cdd1
a8e8837
d324958
152f45b
87bb90c
054351a
96019f3
2f729ca
b922b42
d1ecd8e
69f8b54
e0ca0cf
122c8dc
e53baae
8fe1b94
6024bc0
c169a34
31044e0
165037b
c66b795
b28d1e5
70fc563
7602243
a14f68f
557f5c9
2c8467a
4702b44
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /node_modules | ||
| /.next/ | ||
| .DS_Store | ||
| tsconfig.tsbuildinfo |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # example-app-router-patterns | ||
|
|
||
| This example demonstrates various use cases and patterns for using `next-intl` with the App Router. | ||
|
|
||
| ## Deploy your own | ||
|
|
||
| By deploying to [Vercel](https://vercel.com), you can check out the example in action. Note that you'll be prompted to create a new GitHub repository as part of this, allowing you to make subsequent changes. | ||
|
|
||
| [](https://vercel.com/new/clone?repository-url=https://github.com/amannn/next-intl/tree/main/examples/example-app-router-patterns) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import {dirname} from 'path'; | ||
| import {fileURLToPath} from 'url'; | ||
| import {FlatCompat} from '@eslint/eslintrc'; | ||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = dirname(__filename); | ||
|
|
||
| const compat = new FlatCompat({ | ||
| baseDirectory: __dirname | ||
| }); | ||
|
|
||
| const eslintConfig = [ | ||
| ...compat.extends('next/core-web-vitals', 'next/typescript'), | ||
| { | ||
| rules: { | ||
| '@typescript-eslint/no-explicit-any': 'off', | ||
| '@typescript-eslint/ban-ts-comment': 'off' | ||
| } | ||
| } | ||
| ]; | ||
|
|
||
| export default eslintConfig; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import type { MDXComponents } from 'mdx/types'; | ||
| import { Code } from '@/components/code/code'; | ||
|
|
||
| export function useMDXComponents(components: MDXComponents): MDXComponents { | ||
| return { Code, ...components }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "Layout": { | ||
| "title": "next-intl Playground", | ||
| "tagline": "Übersetzungen, Formatierung, Routing und Patterns mit Next.js.", | ||
| "examples": "Beispiele" | ||
| }, | ||
| "Nav": { | ||
| "translations": "Übersetzungen", | ||
| "serverComponents": "Server-Komponenten", | ||
| "serverComponentsDescription": "Übersetzte Strings in Server-Komponenten lesen.", | ||
| "clientComponents": "Client-Komponenten", | ||
| "clientComponentsDescription": "Übersetzungen in interaktiven Client-Komponenten nutzen." | ||
| }, | ||
| "ServerComponentsPage": { | ||
| "title": "Server-Komponenten", | ||
| "subtitle": "Übersetzungen", | ||
| "output": "Ausgabe", | ||
| "greeting": "Hallo, Welt!" | ||
| }, | ||
| "ClientComponentsPage": { | ||
| "title": "Client-Komponenten", | ||
| "subtitle": "Übersetzungen", | ||
| "output": "Ausgabe", | ||
| "label": "Dein Name", | ||
| "placeholder": "Frodo", | ||
| "greeting": "Hallo, {name}!" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| { | ||
| "Layout": { | ||
| "title": "next-intl playground", | ||
| "tagline": "Translations, formatting, routing and patterns with Next.js.", | ||
| "examples": "Examples" | ||
| }, | ||
| "Nav": { | ||
| "translations": "Translations", | ||
| "serverComponents": "Server Components", | ||
| "serverComponentsDescription": "Read translated strings inside Server Components.", | ||
| "clientComponents": "Client Components", | ||
| "clientComponentsDescription": "Use translations in interactive Client Components." | ||
| }, | ||
| "ServerComponentsPage": { | ||
| "title": "Server Components", | ||
| "subtitle": "Translations", | ||
| "output": "Output", | ||
| "greeting": "Hello, world!" | ||
| }, | ||
| "ClientComponentsPage": { | ||
| "title": "Client Components", | ||
| "subtitle": "Translations", | ||
| "output": "Output", | ||
| "label": "Your name", | ||
| "placeholder": "Frodo", | ||
| "greeting": "Hello, {name}!" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| /// <reference types="next" /> | ||
| /// <reference types="next/image-types/global" /> | ||
| /// <reference path="./.next/types/routes.d.ts" /> | ||
|
|
||
| // NOTE: This file should not be edited | ||
| // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import createMDX from '@next/mdx'; | ||
| import {remarkCodeHike, recmaCodeHike} from 'codehike/mdx'; | ||
| import createNextIntlPlugin from 'next-intl/plugin'; | ||
|
|
||
| /** @type {import('codehike/mdx').CodeHikeConfig} */ | ||
| const chConfig = { | ||
| components: {code: 'Code'} | ||
| }; | ||
|
|
||
| const withMDX = createMDX({ | ||
| extension: /\.mdx?$/, | ||
| options: { | ||
| remarkPlugins: [[remarkCodeHike, chConfig]], | ||
| recmaPlugins: [[recmaCodeHike, chConfig]], | ||
| jsx: true | ||
| } | ||
| }); | ||
|
|
||
| const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); | ||
|
|
||
| /** @type {import('next').NextConfig} */ | ||
| const nextConfig = { | ||
| pageExtensions: ['ts', 'tsx', 'mdx'] | ||
| }; | ||
|
|
||
| export default withNextIntl(withMDX(nextConfig)); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| { | ||
| "name": "example-app-router-patterns", | ||
| "private": true, | ||
| "scripts": { | ||
| "dev": "next dev", | ||
| "lint": "eslint src && prettier src --check", | ||
| "build": "next build", | ||
| "start": "next start" | ||
| }, | ||
| "dependencies": { | ||
| "@mdx-js/loader": "^3.1.0", | ||
| "@mdx-js/react": "^3.1.0", | ||
| "@next/mdx": "^15.5.4", | ||
| "clsx": "^2.1.1", | ||
| "codehike": "^1.0.7", | ||
| "lucide-react": "^0.545.0", | ||
| "next": "^15.5.0", | ||
| "next-intl": "^4.0.0", | ||
| "next-themes": "^0.4.6", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0", | ||
| "tailwindcss": "^4.1.12" | ||
| }, | ||
| "devDependencies": { | ||
| "@eslint/eslintrc": "^3.1.0", | ||
| "@tailwindcss/postcss": "^4.1.12", | ||
| "@types/mdx": "^2.0.13", | ||
| "@types/node": "^20.14.5", | ||
| "@types/react": "^19.0.0", | ||
| "@types/react-dom": "^19.0.0", | ||
| "eslint": "9.11.1", | ||
| "eslint-config-next": "^15.5.0", | ||
| "postcss": "^8.5.3", | ||
| "prettier": "^3.3.3", | ||
| "prettier-plugin-organize-imports": "^4.2.0", | ||
| "prettier-plugin-tailwindcss": "^0.6.14", | ||
| "typescript": "^5.5.3" | ||
| }, | ||
| "prettier": { | ||
| "singleQuote": true, | ||
| "bracketSpacing": false, | ||
| "trailingComma": "none", | ||
| "plugins": [ | ||
| "prettier-plugin-organize-imports", | ||
| "prettier-plugin-tailwindcss" | ||
| ] | ||
| }, | ||
| "engines": { | ||
| "node": ">=20.0.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| const config = { | ||
| plugins: { | ||
| '@tailwindcss/postcss': {} | ||
| } | ||
| }; | ||
| export default config; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import {PlaygroundByline} from '@/components/playground/byline'; | ||
| import {PlaygroundSidebar} from '@/components/playground/sidebar'; | ||
| import {routing} from '@/i18n/routing'; | ||
| import {hasLocale} from 'next-intl'; | ||
| import {setRequestLocale} from 'next-intl/server'; | ||
| import {notFound} from 'next/navigation'; | ||
| import type {ReactNode} from 'react'; | ||
|
|
||
| export function generateStaticParams() { | ||
| return routing.locales.map((locale) => ({locale})); | ||
| } | ||
|
|
||
| type Props = { | ||
| children: ReactNode; | ||
| params: Promise<{locale: string}>; | ||
| }; | ||
|
|
||
| export default async function LocaleLayout({children, params}: Props) { | ||
| const {locale} = await params; | ||
| if (!hasLocale(routing.locales, locale)) notFound(); | ||
| setRequestLocale(locale); | ||
|
|
||
| return ( | ||
| <> | ||
| <PlaygroundSidebar /> | ||
| <div className="lg:pl-72"> | ||
| <div className="mx-auto mt-16 mb-24 max-w-4xl px-4 sm:px-6 lg:mt-0 lg:px-8 lg:py-10"> | ||
| {children} | ||
| <PlaygroundByline /> | ||
| </div> | ||
| </div> | ||
| </> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import {PlaygroundBoundary} from '@/components/playground/boundary'; | ||
| import {LinkStatus} from '@/components/playground/link-status'; | ||
| import {Link} from '@/i18n/navigation'; | ||
| import {sections} from '@/lib/nav'; | ||
| import {ArrowRight} from 'lucide-react'; | ||
| import {useTranslations} from 'next-intl'; | ||
| import {setRequestLocale} from 'next-intl/server'; | ||
|
|
||
| type Props = { | ||
| params: Promise<{locale: string}>; | ||
| }; | ||
|
|
||
| export default async function HomePage({params}: Props) { | ||
| const {locale} = await params; | ||
| setRequestLocale(locale); | ||
| return <Home />; | ||
| } | ||
|
|
||
| function Home() { | ||
| const t = useTranslations('Layout'); | ||
| const tNav = useTranslations('Nav'); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coming from #2084 (comment): I think it's different aspects:
We can keep |
||
|
|
||
| return ( | ||
| <div className="pb-12"> | ||
| <div className="mb-12 pt-8 text-center sm:mb-16 sm:pt-12"> | ||
| <h1 className="text-foreground text-[34px] font-semibold tracking-tight sm:text-5xl"> | ||
| {t('title')} | ||
| </h1> | ||
| <p className="text-muted-foreground mx-auto mt-4 max-w-xl text-base sm:text-lg"> | ||
| {t('tagline')} | ||
| </p> | ||
| </div> | ||
| <PlaygroundBoundary label={t('examples')} className="space-y-10"> | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it |
||
| {sections.map((section) => { | ||
| const Icon = section.icon; | ||
| return ( | ||
| <div key={section.titleKey} className="flex flex-col gap-3"> | ||
| <div className="text-muted-foreground flex items-center gap-2 font-mono text-[10px] font-semibold tracking-[0.18em] uppercase"> | ||
| <Icon className="h-3 w-3" strokeWidth={2} /> | ||
| {tNav(section.titleKey)} | ||
| </div> | ||
| <div className="bg-border grid grid-cols-1 gap-px sm:grid-cols-2"> | ||
| {section.items.map((item) => ( | ||
| <Link | ||
| href={item.slug} | ||
| key={item.slug} | ||
| className="group bg-background hover:bg-muted flex flex-col gap-1.5 px-5 py-4 transition-colors" | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| > | ||
| <div className="text-foreground flex items-center justify-between font-medium"> | ||
| <span className="inline-flex items-center gap-1.5"> | ||
| {tNav(item.titleKey)} | ||
| <LinkStatus /> | ||
| </span> | ||
| <ArrowRight | ||
| className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-transform group-hover:translate-x-0.5" | ||
| strokeWidth={1.5} | ||
| /> | ||
| </div> | ||
| <div className="text-muted-foreground line-clamp-3 text-[13px]"> | ||
| {tNav(item.descriptionKey)} | ||
| </div> | ||
| </Link> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| })} | ||
| </PlaygroundBoundary> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| 'use client'; | ||
|
|
||
| import {useTranslations} from 'next-intl'; | ||
| import {useState} from 'react'; | ||
|
|
||
| export function ClientExample() { | ||
| const t = useTranslations('ClientComponentsPage'); | ||
| const [name, setName] = useState(''); | ||
|
|
||
| return ( | ||
| <div className="space-y-3"> | ||
| <label | ||
| htmlFor="client-example-name" | ||
| className="text-muted-foreground block text-sm font-medium" | ||
| > | ||
| {t('label')} | ||
| </label> | ||
| <input | ||
| id="client-example-name" | ||
| value={name} | ||
| onChange={(e) => setName(e.target.value)} | ||
| placeholder={t('placeholder')} | ||
| className="border-border bg-background w-full max-w-xs rounded-md border px-3 py-2 text-sm" | ||
| /> | ||
| <p className="text-foreground text-2xl font-semibold"> | ||
| {t('greeting', {name: name || t('placeholder')})} | ||
| </p> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,20 @@ | ||||||
| - `useTranslations` works the same in Client Components, so interactive UI can read messages too. | ||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| - ICU arguments like `{name}` are resolved in the browser as state changes — try typing below. | ||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coming from #2084 (comment): Yeah, I'd switch to inline labels here. Otherwise the locale switcher will appear to be non-working if we have markdown that is not translated. Using inline labels with We can also avoid the
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the "try typing below." is not necessary. There's this phrase I like which I think you already did very well with the input field: Show, don't tell. |
||||||
|
|
||||||
| ```tsx app/greet.tsx | ||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think |
||||||
| 'use client'; | ||||||
| import {useState} from 'react'; | ||||||
| import {useTranslations} from 'next-intl'; | ||||||
|
|
||||||
| export function Greet() { | ||||||
| const t = useTranslations('ClientComponentsPage'); | ||||||
| const [name, setName] = useState('Frodo'); | ||||||
| return ( | ||||||
| <> | ||||||
| <input value={name} onChange={(e) => setName(e.target.value)} /> | ||||||
| // !mark | ||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| <p>{t('greeting', {name})}</p> | ||||||
| </> | ||||||
| ); | ||||||
| } | ||||||
| ``` | ||||||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use
const {locale} = use(params)to avoid the async/non-async split of components