DEV Community

Cover image for 😱 Static HTML Export with i18n compatibility in Next.js 😱
Adriano Raiano
Adriano Raiano

Posted on • Edited on

😱 Static HTML Export with i18n compatibility in Next.js 😱

You know Next.js, right? - If not, stop reading this article and make something else.

Next.js is awesome! It gives you the best developer experience with all the features you need...

TOC

BUT, you may have heard about this:

Error: i18n support is not compatible with next export. See here for more info on deploying: https://nextjs.org/docs/deployment

This happens if you're using the internationalized routing feature and are trying to generate a static HTML export by executing next export.
Well, this features requires a Node.js server, or dynamic logic that cannot be computed during the build process, that's why it is unsupported.

This is the case if you're using next-i18next for example.

So what can we do now?

what

An obvious option is, to renounce to the static HTML export and use a Node.js server or Vercel as deployment environment.

But sometimes, due to company or architectural guidelines it is mandatory to use a static web server.


Ok then renounce to i18n? - Not really, if we are here, it seems like to be a requirement.


So then do it without Next.js? - But this usually means to rewrite the whole project.

Executing next export when not using i18n seems to work.
What if we do not try to use the internationalized routing feature and do the i18n routing on our own?

The recipe

recipe

To "cook" this recipe you will need the following ingredients:

  • use the dynamic route segments feature
  • willingness to change the structure of your project files
  • willingness to adapt a bit of code
  • a logic to detect the user language and redirect accordingly

Sounds feasible. Let's start!

1. Remove the i18n options from next.config.js.



  - const { i18n } = require('./next-i18next.config')
  - 
  module.exports = {
  -   i18n,
    trailingSlash: true,
  }


Enter fullscreen mode Exit fullscreen mode

2. Create a [locale] folder inside your pages directory.

a) Move all your pages files to that folder (not _app.js or _document.js etc..).

b) Adapt your imports, if needed.

3. Create a getStatic.js file and place it for example in a lib directory.



  import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
  import i18nextConfig from '../next-i18next.config'

  export const getI18nPaths = () =>
    i18nextConfig.i18n.locales.map((lng) => ({
      params: {
        locale: lng
      }
    }))

  export const getStaticPaths = () => ({
    fallback: false,
    paths: getI18nPaths()
  })

  export async function getI18nProps(ctx, ns = ['common']) {
    const locale = ctx?.params?.locale
    let props = {
      ...(await serverSideTranslations(locale, ns))
    }
    return props
  }

  export function makeStaticProps(ns = {}) {
    return async function getStaticProps(ctx) {
      return {
        props: await getI18nProps(ctx, ns)
      }
    }
  }


Enter fullscreen mode Exit fullscreen mode

4. Use getStaticPaths and makeStaticProps in your pages, like this:



  import { useTranslation } from 'next-i18next'
  import { getStaticPaths, makeStaticProps } from '../../lib/getStatic'
  import { Header } from '../../components/Header'
  import { Footer } from '../../components/Footer'
  import Link from '../../components/Link'

  + const getStaticProps = makeStaticProps(['common', 'footer'])
  + export { getStaticPaths, getStaticProps }

  const Homepage = () => {
    const { t } = useTranslation('common')

    return (
      <>
        <main>
          <Header heading={t('h1')} title={t('title')} />
          <div>
            <Link href='/second-page'><button type='button'>{t('to-second-page')}</button></Link>
          </div>
        </main>
        <Footer />
      </>
    )
  }

  export default Homepage


Enter fullscreen mode Exit fullscreen mode

5. Install next-language-detector.

npm i next-language-detector

6. Create a languageDetector.js file and place it for example in the lib directory.



  import languageDetector from 'next-language-detector'
  import i18nextConfig from '../next-i18next.config'

  export default languageDetector({
    supportedLngs: i18nextConfig.i18n.locales,
    fallbackLng: i18nextConfig.i18n.defaultLocale
  })


Enter fullscreen mode Exit fullscreen mode

7. Create a redirect.js file and place it for example in the lib directory.



  import { useEffect } from 'react'
  import { useRouter } from 'next/router'
  import languageDetector from './languageDetector'

  export const useRedirect = (to) => {
    const router = useRouter()
    to = to || router.asPath

    // language detection
    useEffect(() => {
      const detectedLng = languageDetector.detect()
      if (to.startsWith('/' + detectedLng) && router.route === '/404') { // prevent endless loop
        router.replace('/' + detectedLng + router.route)
        return
      }

      languageDetector.cache(detectedLng)
      router.replace('/' + detectedLng + to)
    })

    return <></>
  };

  export const Redirect = () => {
    useRedirect()
    return <></>
  }

  // eslint-disable-next-line react/display-name
  export const getRedirect = (to) => () => {
    useRedirect(to)
    return <></>
  }


Enter fullscreen mode Exit fullscreen mode

8. For each of your pages files in your [locale] directory, but especially for the index.js file, create a file with the same name with this content:



  import { Redirect } from '../lib/redirect'
  export default Redirect


Enter fullscreen mode Exit fullscreen mode

9. Create a Link.js component and place it for example in the components directory.



  import React from 'react'
  import Link from 'next/link'
  import { useRouter } from 'next/router'

  const LinkComponent = ({ children, skipLocaleHandling, ...rest }) => {
    const router = useRouter()
    const locale = rest.locale || router.query.locale || ''

    let href = rest.href || router.asPath
    if (href.indexOf('http') === 0) skipLocaleHandling = true
    if (locale && !skipLocaleHandling) {
      href = href
        ? `/${locale}${href}`
        : router.pathname.replace('[locale]', locale)
    }

    return (
      <>
        <Link href={href}>
          <a {...rest}>{children}</a>
        </Link>
      </>
    )
  }

  export default LinkComponent


Enter fullscreen mode Exit fullscreen mode

10. Replace al next/link Link imports with the appropriate ../components/Link Link import:



  - import Link from 'next/link'
  + import Link from '../../components/Link'


Enter fullscreen mode Exit fullscreen mode

11. Add or modify your _document.js file to set the correct html lang attribute:



  import Document, { Html, Head, Main, NextScript } from 'next/document'
  import i18nextConfig from '../next-i18next.config'

  class MyDocument extends Document {
    render() {
      const currentLocale = this.props.__NEXT_DATA__.query.locale || i18nextConfig.i18n.defaultLocale
      return (
        <Html lang={currentLocale}>
          <Head />
          <body>
            <Main />
            <NextScript />
          </body>
        </Html>
      )
    }
  }

  export default MyDocument


Enter fullscreen mode Exit fullscreen mode

12. In case you have a language switcher, create or adapt it:



  // components/LanguageSwitchLink.js
  import languageDetector from '../lib/languageDetector'
  import { useRouter } from 'next/router'
  import Link from 'next/link'

  const LanguageSwitchLink = ({ locale, ...rest }) => {
    const router = useRouter()

    let href = rest.href || router.asPath
    let pName = router.pathname
    Object.keys(router.query).forEach((k) => {
      if (k === 'locale') {
        pName = pName.replace(`[${k}]`, locale)
        return
      }
      pName = pName.replace(`[${k}]`, router.query[k])
    })
    if (locale) {
      href = rest.href ? `/${locale}${rest.href}` : pName
    }

    return (
      <Link
        href={href}
        onClick={() => languageDetector.cache(locale)}
      >
        <button style={{ fontSize: 'small' }}>{locale}</button>
      </Link>
    );
  };

  export default LanguageSwitchLink


Enter fullscreen mode Exit fullscreen mode


  // components/Footer.js
  import { useTranslation } from 'next-i18next'
  import { useRouter } from 'next/router'
  import LanguageSwitchLink from './LanguageSwitchLink'
  import i18nextConfig from '../next-i18next.config'

  export const Footer = () => {
    const router = useRouter()
    const { t } = useTranslation('footer')
    const currentLocale = router.query.locale || i18nextConfig.i18n.defaultLocale

    return (
      <footer>
        <p>
          <span style={{ lineHeight: '4.65em', fontSize: 'small' }}>{t('change-locale')}</span>
          {i18nextConfig.i18n.locales.map((locale) => {
            if (locale === currentLocale) return null
            return (
              <LanguageSwitchLink
                locale={locale}
                key={locale}
              />
            )
          })}
        </p>
      </footer>
    )
  }


Enter fullscreen mode Exit fullscreen mode

The outcome

result

If you now start your project (next dev) you should see, more or less, the same behaviour as before.

So what's the benefit?

Try: next build && next export

You should see something like this at the end:



●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

info  - using build directory: /Users/usr/projects/my-awesome-project/.next
info  - Copying "static build" directory
info  - No "exportPathMap" found in "/Users/usr/projects/my-awesome-project/next.config.js". Generating map from "./pages"
info  - Launching 9 workers
info  - Copying "public" directory
info  - Exporting (3/3)
Export successful. Files written to /Users/usr/projects/my-awesome-project/out


Enter fullscreen mode Exit fullscreen mode

Yeah no i18n support is not compatible with next export error anymore!!!

Congratulations! Now you can "deploy" the content of your out directory to any static web server.

πŸ§‘β€πŸ’» The complete code can be found here.

The voluntary part

translation workflow

Connect to an awesome translation management system and manage your translations outside of your code.

Let's synchronize the translation files with locize.
This can be done on-demand or on the CI-Server or before deploying the app.

What to do to reach this step:

  1. in locize: signup at https://locize.app/register and login
  2. in locize: create a new project
  3. in locize: add all your additional languages (this can also be done via API)
  4. install the locize-cli (npm i locize-cli)

Use the locize-cli

Use the locize sync command to synchronize your local repository (public/locales) with what is published on locize.

Alternatively, you can also use the locize download command to always download the published locize translations to your local repository (public/locales) before bundling your app.

πŸŽ‰πŸ₯³ Congratulations 🎊🎁

I hope you’ve learned a few new things about static site generation (SSG), Next.js, next-i18next, i18next and modern localization workflows.

So if you want to take your i18n topic to the next level, it's worth to try the localization management platform - locize.

The founders of locize are also the creators of i18next. So with using locize you directly support the future of i18next.

πŸ‘


Looking for an optimized Next.js translations setup?

next-i18next
Here you'll find a blog post on how to best use next-i18next with client side translation download and SEO optimization.

Latest comments (55)

Collapse
 
nardoshood profile image
Nardos Tilahun

I followed the same step but for some reason.
const { t } = useTranslation("common");
is not loading the contents.
eg: {t("home1")} {t("home2")} would display home1 home2 in the page.

For context. I am using
"next": "12.3.0",
"next-i18next": "13.0.0",
"next-language-detector": "1.0.2",
How do I solve that?

Collapse
 
nardoshood profile image
Nardos Tilahun

Nvm I was missing.
export default appWithTranslation(MyApp)

Collapse
 
marktangcd profile image
MarkTangCd

Thanks for your article. I used it in Next.js@12.x.x and it can work.

Collapse
 
andryore profile image
Andry Orellana

Hi, thanks for taking the time to make this tutorial, I couldn't find a solution for this problem.
I am trying to implement it but I have problems with TS, I have not been able to create the Link component.
Any tutorial on this but using TS?

Collapse
 
tangopj profile image
TangoPJ • Edited

Hello, what did you mean in the 8th point of your code? If I understand you correctly - I should create an index.tsx file in the pages directory with the Redirect code? (in my case I have a single index.tsx page)
My structure looks like something like this:

Image description

Collapse
 
adrai profile image
Adriano Raiano
Collapse
 
tangopj profile image
TangoPJ

Thank you so much, it worked for me, but I didn't understand anything :D

Collapse
 
zecka profile image
zecka

But with this approach is the language in url path is mandatory. You can not have empty path for default language, right ?

For example to have that:

  • example.com/my-page (default-language)
  • example.com/fr/my-page

or maybe the only way is to duplicate all your page like bellow:

  • pages/[locale]/[slug].js
  • pages/[slug].js

Any way to avoid duplicating pages ?

Collapse
 
bluepuper profile image
Bluepuper

Can't get what duplicate file names should be on step 8? Can anyone explain me?

Collapse
 
adrai profile image
Adriano Raiano

check the code and you’ll see: github.com/i18next/next-language-d...

Collapse
 
bluepuper profile image
Bluepuper

Oh thanks, but what if i have locale/dashbord/analytics.js
What name of the file should be in pages dir? dashboard.analytics.js ?

Collapse
 
yrobot profile image
Yrobot

Hi guys. I have just published a package for i18n next.js project SSG export, which called i18next-ssg.

With i18next-ssg, you could handle the i18n logic for next.js SSG easily.

Here is an example, if you have the pages defined like this:

Image description

After you run yarn build, the output files under the build folder will look like this:

Image description

/ will redirect to /[locale],/arya will redirect to /[locale]/arya, the locale value will be detected automaticaly.

This is the demo for this package: codesandbox.io/s/i18next-ssg-ozrpwx
This is the package github repo: github.com/Yrobot/i18next-ssg

Collapse
 
coeneivan profile image
Ivan Coene

THANK YOU!!!!!
I've been searching for a solution for quite a while now!
Great idea!

Just added this snippet to my _app.js to make it even nicer instead of step 8

useEffect(() => {
     if (router.query.locale !== undefined ) {
       const qLng = typeof router.query.locale == 'string' ? router.query.locale : router.query.locale[0] 
       if (!i18nConfig.i18n.locales.includes(qLng)) {
          router.replace(`${language}/${qLng}`)
        }
      }
  }, [router.query.locale]);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
numito profile image
Numa • Edited

Hello,
The only issue with this solution is that next router doesn't take into account the locale, so I had to override it as follow to add the locale when calling "push":

import {useRouter as _useRouter} from "next/router";
import i18nConfig from '../../next-i18next.config';
import {UrlObject} from "url";
declare type Url = UrlObject | string;
interface TransitionOptions {
  shallow?: boolean;
  locale?: string | false;
  scroll?: boolean;
}

export const useRouter = () => {
  const router = _useRouter();
  const locale = router.query.locale as string || '';
  if (!router.locale && locale)
    router.locale = locale;
  if (!router.defaultLocale && i18nConfig.i18n.defaultLocale)
    router.defaultLocale = i18nConfig.i18n.defaultLocale;
  if (!router.locales && i18nConfig.i18n.locales)
    router.locales = i18nConfig.i18n.locales;
  const oldPush = router.push;
  //we override the push method to add the current locale
  router.push = (url: Url, as?: Url, options?: TransitionOptions): Promise<boolean> => {
      if (typeof url == "string" && !url.startsWith(`/${locale}`))
        url = `/${locale}${url}`;
      else if (typeof url === 'object' && url.pathname && !url.pathname.startsWith(`/${locale}`))
        url.pathname = `/${locale}${url.pathname}`;
    return oldPush.apply(this, [url, as, options]);
  }
  return router;
}
Enter fullscreen mode Exit fullscreen mode

Anyone came with a better solution ? It seems a bit of a hack!