← Назад к вопросам

Как реализуешь приложение с мультиязычностью для динамических страниц создаваемых через админку?

1.8 Middle🔥 123 комментариев
#JavaScript Core

Комментарии (3)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Мультиязычность для динамических страниц из админки

Реализация i18n для динамического контента, создаваемого через админку - одна из самых сложных задач в веб-разработке. Подход зависит от архитектуры приложения, но основная идея - разделить структуру контента и переводы.

Подходы к реализации

1. Переводы хранятся в БД (Database-first approach) Это самый гибкий подход для динамического контента:

// Структура в базе данных
// Table: pages
{
  id: "UUID",
  slug: "about-us",
  template: "standard", // какой шаблон использовать
  created_at: "timestamp"
}

// Table: page_translations
{
  id: "UUID",
  page_id: "UUID",
  language: "en",
  title: "About Us",
  content: "<h1>About Us</h1>...",
  meta_description: "...",
  slug: "about-us",
  created_at: "timestamp"
}

// Frontend - компонент для отображения динамической страницы
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

function DynamicPage() {
  const { locale } = useRouter(); // "en", "ru", "de"
  const { slug } = useRouter().query;
  const [page, setPage] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!slug) return;

    // Запрашиваем перевод на нужном языке
    fetch(`/api/pages/${slug}?lang=${locale}`)
      .then(res => res.json())
      .then(data => {
        setPage(data);
        setLoading(false);
      })
      .catch(err => {
        console.error("Failed to fetch page:", err);
        setLoading(false);
      });
  }, [slug, locale]);

  if (loading) return <div>Loading...</div>;
  if (!page) return <div>Page not found</div>;

  return (
    <article>
      <h1>{page.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.content) }} />
    </article>
  );
}

export default DynamicPage;

2. Структура API для админки

// API endpoint для получения страницы
// GET /api/pages/{slug}?lang=en
Response:
{
  id: "uuid",
  slug: "about-us",
  template: "standard",
  translations: {
    en: {
      title: "About Us",
      content: "...",
      meta_description: "...",
      slug: "about-us"
    },
    ru: {
      title: "О нас",
      content: "...",
      meta_description: "...",
      slug: "o-nas"
    }
  }
}

// API endpoint для создания/обновления страницы
// POST/PUT /api/pages
{
  slug: "about-us",
  template: "standard",
  translations: {
    en: { title: "About Us", content: "...", ... },
    ru: { title: "О нас", content: "...", ... }
  }
}

Реализация с next-i18next

// next-i18next конфиг для работы с админ-контентом
// next-i18next.config.js

module.exports = {
  i18n: {
    defaultLocale: "en",
    locales: ["en", "ru", "de"],
  },
  localePath: "./public/locales", // для статических переводов
  ns: ["common", "pages"], // пространства имён
};

// Комбинированный подход: статические + динамические переводы
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";

function PageComponent() {
  const { t } = useTranslation("common");
  const [dynamicContent, setDynamicContent] = useState(null);

  // Статический перевод меню, кнопок
  const navigationLabel = t("pages.navigation.home");

  // Динамический контент из БД
  useEffect(() => {
    const locale = useRouter().locale;
    fetch(`/api/pages/${slug}?lang=${locale}`)
      .then(res => res.json())
      .then(data => setDynamicContent(data));
  }, [slug]);

  return (
    <div>
      <nav>{navigationLabel}</nav>
      {dynamicContent && (
        <h1>{dynamicContent.title}</h1>
      )}
    </div>
  );
}

export async function getStaticProps({ locale }) {
  return {
    props: {
      ...(await serverSideTranslations(locale, ["common", "pages"])),
    },
  };
}

export default PageComponent;

Продвинутый подход: Contentful + i18n

// Если используется Contentful (CMS as a Service)
import { createClient } from "contentful";

const client = createClient({
  space: process.env.CONTENTFUL_SPACE,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
});

async function getPageFromContentful(locale) {
  const entries = await client.getEntries({
    content_type: "page",
    "fields.slug": slug,
    locale: locale, // Contentful автоматически возвращает нужный язык
  });

  return entries.items[0]?.fields || null;
}

// Page component
function DynamicPage() {
  const [page, setPage] = useState(null);
  const { locale } = useRouter();

  useEffect(() => {
    getPageFromContentful(locale).then(setPage);
  }, [locale]);

  return (
    <article>
      {page && (
        <>
          <h1>{page.title}</h1>
          <RichTextRenderer document={page.content} />
        </>
      )}
    </article>
  );
}

Обработка URL с разными слагами по языкам

// next.config.js
module.exports = {
  i18n: {
    locales: ["en", "ru"],
    defaultLocale: "en",
  },
  rewrites: async () => {
    return {
      beforeFiles: [
        // Переписываем URL /o-nas на /pages/about-us?lang=ru
        {
          source: "/o-nas",
          destination: "/pages/about-us?lang=ru",
          locale: false,
        },
        {
          source: "/about-us",
          destination: "/pages/about-us?lang=en",
          locale: false,
        },
      ],
    };
  },
};

// Или используем динамическую маршрутизацию
// pages/[...slug].js
import { useRouter } from "next/router";

function DynamicPage() {
  const { slug } = useRouter().query;
  const { locale } = useRouter();
  
  // Определяем slug на основе текущего языка
  const normalizedSlug = normalizeSlug(slug, locale);

  // Запрашиваем страницу
  const page = fetchPage(normalizedSlug, locale);

  return <PageRenderer page={page} />;
}

function normalizeSlug(slug, locale) {
  // Если пользователь на /ru/o-nas, преобразуем в about-us
  const slugMap = {
    ru: {
      "o-nas": "about-us",
      "kontakty": "contacts",
    },
    en: {
      "about-us": "about-us",
      "contacts": "contacts",
    },
  };

  return slugMap[locale]?.[slug] || slug;
}

Кэширование переводов

// Используем Redis для кэширования часто запрашиваемых страниц
import redis from "./redis-client";

async function getPage(slug, language) {
  const cacheKey = `page:${slug}:${language}`;

  // Проверяем кэш
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // Если нет в кэше - берём из БД
  const page = await db.query(
    "SELECT * FROM page_translations WHERE slug = ? AND language = ?",
    [slug, language]
  );

  // Кэшируем на 1 час
  await redis.set(cacheKey, JSON.stringify(page), "EX", 3600);

  return page;
}

Админка для управления переводами

// React компонент для админки (используя React Hook Form)
import { useForm, useFieldArray } from "react-hook-form";
import { useRouter } from "next/router";

const LANGUAGES = ["en", "ru", "de"];

function PageEditForm({ pageId }) {
  const { control, register, handleSubmit } = useForm({
    defaultValues: async () => {
      const page = await fetch(`/api/pages/${pageId}`).then(r => r.json());
      return page;
    },
  });

  const { fields: translations } = useFieldArray({
    control,
    name: "translations",
  });

  const onSubmit = async (data) => {
    const response = await fetch(`/api/pages/${pageId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    if (response.ok) {
      alert("Page updated successfully");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {LANGUAGES.map((lang) => (
        <fieldset key={lang}>
          <legend>{lang.toUpperCase()}</legend>

          <label>
            Title ({lang})
            <input
              {...register(`translations.${lang}.title`)}
              placeholder={`Page title in ${lang}`}
            />
          </label>

          <label>
            Slug ({lang})
            <input
              {...register(`translations.${lang}.slug`)}
              placeholder={`URL slug in ${lang}`}
            />
          </label>

          <label>
            Content ({lang})
            <RichTextEditor
              control={control}
              name={`translations.${lang}.content`}
            />
          </label>

          <label>
            Meta Description
            <textarea
              {...register(`translations.${lang}.meta_description`)}
              rows={2}
            />
          </label>
        </fieldset>
      ))}

      <button type="submit">Save Page</button>
    </form>
  );
}

export default PageEditForm;

SEO-оптимизация для мультиязычности

// Генерируем hreflang ссылки для поисковиков
function generateHrefLang(slug, translations) {
  return (
    <>
      {Object.entries(translations).map(([lang, data]) => (
        <link
          key={lang}
          rel="alternate"
          hrefLang={lang}
          href={`${process.env.SITE_URL}/${lang}/${data.slug}`}
        />
      ))}
      <link
        rel="alternate"
        hrefLang="x-default"
        href={`${process.env.SITE_URL}/${translations.en.slug}`}
      />
    </>
  );
}

// Используем в Next.js Head
import Head from "next/head";

function DynamicPage({ page }) {
  return (
    <Head>
      <title>{page.title}</title>
      <meta name="description" content={page.meta_description} />
      {generateHrefLang(page.slug, page.translations)}
    </Head>
  );
}

Лучшие практики

  1. Разделяй интерфейс и контент - UI переводы в next-i18next, контент в БД
  2. Кэшируй агрессивно - используй Redis для часто запрашиваемых страниц
  3. Валидируй переводы - проверяй наличие перевода на всех языках перед публикацией
  4. Версионируй контент - сохраняй историю изменений для отката
  5. SEO-friendly - используй правильные hreflang теги и уникальные URL по языкам
  6. Документируй - объясни админам правила создания переводов

Этот подход масштабируется от небольших сайтов до крупных мультиязычных платформ.