Internationalization
Next.js i18n: From DB, Metadata to SEO Optimization
Intro
As I expanded the project, I found myself mixing Korean and English purely for my own convenience. However, seeing the final result—where deep-dive blog posts were in Korean while entry points like the timeline were in English—I realized this wasn’t a great experience for users, regardless of which language they preferred. Instead of forcing everything into a single language and throwing away existing content, I decided to fully embrace next-intl, which I had initially set up only for temporary shared messaging.
I intentionally expanded the scope of i18n in this project to cover as much ground as possible: moving beyond simple UI labels to providing translations at the database level for all text content, and even applying i18n to site metadata for SEO optimization.
This post is based on Next.js 16+ and the App Router.
Configuration
Before diving into the specific implementation, the first step was to configure the environment for display. next-intl is a lightweight i18n library specifically designed for Next.js applications. I followed the official documentation for the basic setup and implemented the experimental feature for type generation to enable message.json auto-completion and compile-time type checking.
Strategy
There are several strategies for implementing i18n. Here are some of the most representative ones:
- Domain Separation: A method where services are provided through separate domains, such as
example.co.kranden.example.com. This approach works best for large-scale services with dedicated localization teams to provide differentiated services by region, allowing for distinct local operational and caching strategies. - Local Storage / Client Side Global State: A method that manages language settings internally within the user's environment. It is relatively easy to implement without additional routing configurations.
- Cookie: By managing language settings through browser memory, this approach allows for an SSR-friendly environment and maintains language preferences at the browser level.
- Locale Prefix: A strategy that includes language codes in the URL, such as
/ko/aboutor/en/about. While it requires significant additional configuration for URL separation, it is highly advantageous for SEO.
I concluded that the benefits of the Domain Separation strategy are difficult to realize unless localization is managed at a team level—not to mention the extra cost—so I compared the remaining strategies.
| Comparison | Global State | Cookie | Locale-Prefix |
|---|---|---|---|
| Core Mechanism | State management in app memory | HTTP headers and browser storage | URL paths (/ko, /en) |
| Implementation Complexity | Low (State change) | Medium (Server/Client comms) | High (Structural changes to all pages) |
| Data Consistency | Not maintained on refresh | Maintained during browser session | Explicit in URL; shareable/bookmarkable |
| User Experience (UX) | Instant reflection (No refresh) | Server data can update without refresh | Transitions via server navigation (Feels like a refresh) |
| SEO Optimization | Impossible (Hard for engines to index) | Limited (Single URL prevents language-specific crawling) | Excellent (Unique URL for each language) |
For this project, I chose the Locale-Prefix approach, considering the single Next.js environment, the Server-Side Rendering (SSR) based architecture, and the goal of exploring and implementing SEO optimization strategies.
Locale Prefix
Reconfiguring the entire URL structure to include language codes is no easy task. When you consider how to handle users accessing URLs without a locale or how to inject language codes into internal navigation, the overhead can be high enough to make you reconsider adopting i18n altogether. For instance:
- Rewriting and applying both sitemaps and metadata based on the locale.
- Checking for missing locales every time an internal link is added.
- Handling locale validation and exception processing for internal links.
However, accepting these costs is necessary to consistently manage language-specific URLs, metadata, and sitemaps. next-intl allows us to replace these manual tasks with just a few simple configurations. This was one of the key reasons I chose the locale-prefix approach.
First, I specified the supported language codes. The routing defined here acts as the "single source of truth," serving as the standard for all language support scopes moving forward.
This file handles all the language code injection issues mentioned earlier. usePathname retrieves the pathname from the current URL excluding the language code, while Link automatically injects the language code into href attributes that lack one before navigating. Note that you must update all existing Next.js default import paths from next/* to point to this file instead.
Next, created a file to synchronize the server-side and client-side language settings.
By wrapping the Next.js configuration with the plugin adapter, the locale prefix is applied to all configured routes.
Next, the basic setup is nearly complete once you configure the middleware to exclude paths for static assets and API requests. One important thing to note is the filename: while it was middleware.ts up to Next.js 15, it must be named proxy.ts starting from Next.js 16.
Finally, as the name "locale prefix" suggests, you must move all files within the app folder into a [locale] subdirectory. By using generateStaticParams to generate parameters for the language codes defined in routing.ts, the basic configuration for i18n is complete.
Throughout this process, the system is designed to reference routing.ts for all language-related information, simplifying the task of adding or modifying language codes. In this project, formatting patterns are also defined in that same file, making it the central hub for all related configurations, much like the locale settings themselves.
TypeScript Augmentation
Additionally, next-intl supports the generation of type declaration files for enhanced type safety. This feature improves development efficiency by enabling IDE auto-completion for messages defined in json files and supporting type checking during compilation. However, since this is not an officially supported feature in TypeScript (as discussed in this issue), it requires additional configuration.
First, update tsconfig.json to allow JSON types.
In the Next.js configuration file, enable the experimental plugin feature createMessageDeclaration. Once activated, running next dev, next build, or next typegen will automatically generate a type declaration file in the messages directory.
This file treats a single reference locale (JSON) as the source of truth, generating a premise that all languages share the same message structure. It is then used for messages type referencing. Since these files are also automatically generated in CI environments, it is recommended to add them to .gitignore to prevent version control conflicts.
This process links the Locale information and the type declaration file generated from the JSON to the next-intl module. It is the final configuration step that enables IDE auto-completion and type checking.
By examining
next-intl'sMessageKeys.d.ts, it is possible to see how type inference is achieved using strings and dot notation through Template Literal Types withinuseTranslations.
VSCode Integration
Among VSCode extensions, i18n-ally (lokalise.i18n-ally) is a powerful tool that allows developers to preview messages configured in next-intl directly within the IDE or share feedback on specific localizations. Since it supports leaving comments, change requests, and edit histories in YAML format for easy sharing, its adoption is highly recommended for teams managing i18n collaboratively.
ICU Messages
next-intl categorizes messages using json files in a key/translation format and supports diverse cultural language expressions by adopting the ICU Message syntax. This allows messages to be structured around usage context in UI, avoiding cluttered notations such as "person(s)."
In Korean, it is often easy to overlook the need for message segmentation because cases where forms change based on gender or person are less common. Consequently, using combined markers like "은(는)" or "이(가)" is often accepted without disrupting the context. However, in languages such as Spanish, where gender and number significantly impact grammatical structure, these granular message formats prove to be extremely beneficial.
Detailed examples and usage guidelines can be found here.
Message Structure
The NextIntlClientProvider, designed for client-side rendering, fundamentally receives the necessary props from the server. Based on these props, it restores serialized information from the server into a React Context format suitable for client-side rendering.
Due to the nature of referencing the nearest Provider, it is possible to provide messages separately by configuring providers with specific props. However, physically separating message files causes the previously configured type augmentation to stop working. To reapply it, every separated message file must undergo a conversion process into a d.ts file and then be merged again using the spread operator in global.d.ts, which significantly increases complexity.
Instead, next-intl supports the namespace pattern.
In this project, messages are structured by distinguishing between common UI elements used across multiple pages and messages specific to individual page routes. By organizing messages based on namespaces, the size of the message bundle sent to the client can be optimized as shown below.
Since NextIntlClientProvider expects the full message shape registered in global.d.ts, it is necessary to bypass the type check using blogMessages as any when sending only a partial set of messages. Type safety can still be maintained without additional checks by restricting the namespace within useTranslations.
On the server, loading the full message set from local storage does not impose significant overhead. However, on the client side, this can lead to overhead as data must be transferred over the network. While not implemented in this project, if the message size becomes large enough to impact performance, the bundle size can be managed by splitting and sending only the specific messages required by each provider.
Static Metadata i18n
Leveraging messages and locale prefixes offers the additional advantage of providing metadata tailored to each specific locale.
getTranslations is the server-side counterpart to useTranslations, capable of retrieving messages corresponding to the configured locale and a given namespace. This allows for providing metadata tailored to language settings even for static routes.
| Locale | title | description |
|---|---|---|
| ko | 블로그 | 블로그 설명 |
| en | Blog | Blog description |
In this manner, metadata for statically generated pages can be managed through JSON files.
Database Translation
While i18n can be applied to messages and metadata for statically generated pages using JSON files, this project also relies on database-driven dynamic routing. Since the metadata for each page is generated from database records, the schema needed to be redesigned to support i18n for these dynamically generated pages.
The existing database included several columns affected by language settings, such as title, description, content, and word_count. Rather than simply adding translation columns to the existing table, a dedicated translation table was introduced to model locale-specific content separately.
In this project, a font that only supports Latin-based characters is used for titles. To maintain layout consistency, the title was excluded from translation. Additionally, given the personal nature of the project, there was no significant need to track separate creation or modification dates for each translation, so these were not separated into the relation table. The locale field is implemented as an enum, using a custom data type that aligns with the language codes defined in routing.ts.
Database Package
The Prisma DB package used for database access was also updated accordingly. First, the types.ts file was separated to allow for the use of the newly added Locale enum type.
Additionally, since the data is now split based on locale, a fallback mechanism was added to handle data requests where the specific locale content is unavailable.
This part can be configured by receiving the DEFAULT_LOCALE value as an environment variable (ENV). Safeguards were also implemented within the actual data fetching queries.
Cache Layer
The fallback mechanism for locales was also implemented within the separated Next.js cache layer.
Since this part falls within the scope of the Next.js application, consistency is maintained by referencing the routing.ts file. Additionally, the cache tags were extended to allow for granular cache control based on locale classification.
With this, an environment has been established where adding or modifying language support can be fully covered by managing only three areas: routing.ts, the static message JSON files, and the database Locale enum.
NOTE: While it is possible to set a fallback for static message JSON files, the getMessageFallback provided in getRequestConfig only supports synchronous processing. This means it cannot selectively load required parts, and setting it as a fallback would require loading all messages twice. Since these messages are essential anyway, it is recommended to complete the JSON files for the supported languages before adding the language code.
SEO Optimization
The data sources for dynamic generation and display are now ready. The next question is: how should this data be presented?
The project was already displaying metadata and content based on the database. Since the slug for dynamically generated blog pages was already handled using the post id, and the schema was originally designed with the premise of using the title and summary as metadata, applying i18n did not pose a threat to the core logic.
However, as the translation content is now split based on locale, multiple sets of metadata are generated for what effectively represents the same underlying content. Furthermore, with the locale-prefix applied to all site URLs, the original URLs without a prefix have become non-existent. Simply specifying a URL with a locale in the metadata without further context would cause search engines to treat them as entirely separate pages rather than different language versions of the same content.
At this point, two critical elements for search engine optimization emerge:
- Alternates: An attribute that specifies different versions of a URL represent the same content.
- Canonical: The primary URL that search crawlers should treat as the authoritative version among several URLs.
These two elements are essential for the URL configuration of all metadata. Without them, search engines might perceive identical content in different languages as separate pages, leading to "keyword cannibalization" where the pages compete against each other in search results. To prevent this confusion, it is safer and more accurate to explicitly define these relationships using Alternates and Canonical tags.
Alternates Generation
Google's detailed policies and implementation methods for localized page configurations can be found in their Tell Google about localized versions of your page documentation. It mandates specifying corresponding language pages for every page and also covers the x-default setting, which represents the default language. As a result, URL generation was treated as a cross-cutting concern that must be applied across the entire project and was abstracted into a common utility.
The implementation was carried out with the following considerations in mind:
server-only: Since metadata is part of theHTMLhead, the module was explicitly marked as server-side only.routing.ts: URL generation is based on the locales declared in this file to maintain a single source of truth.new URL(): The URL API was used to ensure address standardization and consistency.getLocale: The URL including the user's current locale was designated as the canonical standard.x-default: A baseline was established to determine which version to display by default for users outside of the project's supported language regions.
URL Application
By adding just a few more lines to the blog/[id]/layout.tsx file we looked at earlier, you can specify both the alternates and the canonical URL.
Since the metadata.ts utility was already configured to handle language codes based on existing internal URLs, all necessary addresses can be processed using the same internal link paths (href) used with the Link component.
The canonical URL was intentionally handled separately rather than being grouped entirely under alternates, in order to maintain continuity between the URL shared via openGraph and the specific language context the user is currently experiencing.
Sitemap Generation
Finally, it is time to create an index that search engines can reference to correctly understand the URL structure separated by i18n.
Initially, this project had introduced next-sitemap on a trial basis. While it was adopted as a lightweight tool to explore sitemap structures and generation criteria, additional configurations were required to reflect the dynamic pages set up so far. However, this introduced the burden of managing disparate generation timelines for static and dynamic pages, along with the necessity of integration into the CI process, which increased the scope of required maintenance.
Consequently, I transitioned away from that library in favor of Next.js's built-in sitemap.ts. Instead of pre-generating a physical xml file, sitemap.ts dynamically generates and serves the xml response at the moment the /sitemap.xml request is made.
The key here lies in standardizing addresses using the new URL API—just as in the utility—and automating the registration of alternates for every address generated per language. By registering all language-specific URLs together in the sitemap, you ensure the bidirectionality required by Google's locale-adaptive crawling policy, which is a crucial detail not to be overlooked.
Afterward, by passing the database's modification timestamps to the lastModified field, the automation for dynamically generated pages is successfully completed.
Trade Offs
The Locale Prefix-based i18n approach discussed so far has a disadvantage compared to the Global State or Cookie methods: it triggers a full re-render of all components during language switching. This occurs because, within the Next.js structure, all pages are nested under the [locale] dynamic route.
However, language switching is not a frequent user action, and the current goal was the implementation and overall design of the i18n system itself. Further performance improvements and UX optimizations regarding this behavior will be addressed in the future.
Closing
For a personal project, i18n is a topic that often starts with the question, "Do I really need to go this far?" It involves more considerations and pitfalls than one might expect, extending far beyond simple UI message translation. I had been putting it off for a while, but I used this opportunity to thoroughly organize all parts of the system affected by i18n.
The core of this task was adhering to one single principle: Treat routing.ts as the single source of truth to consistently manage all language boundaries—including links, messages, the database, metadata, and the sitemap. This approach significantly reduced complexity from both a design and operational standpoint, ensuring that changes to language rules in one place propagate throughout the entire system.
i18n is more than just translating strings; it is a global design challenge that touches URL architecture, data models, metadata, and the operational pipeline. Using routing.ts as a single source of truth kept this complexity manageable and greatly reduced uncertainty when adding new languages.
Ultimately, this work prioritizes "consistent design" and "operational predictability" over "perfect optimization."

