Musa Yılmaz
·6 min read

Building Admin-Managed SEO Landing Pages in 15 Languages

nextjsseodjangoi18n

For an airport transfer site, we needed dozens of location-based pages like "Istanbul Airport to Taksim Transfer" — in 15 different languages. Managing this with hand-written static pages per language wasn't sustainable: adding a new location meant needing a developer.

The goal was simple to state: the company should be able to add a new route in the Django admin and see it live, in every language, without a deploy. Getting there took a few decisions that are easy to get wrong, and most of them were about URLs and hreflang rather than rendering.

One model, one page per language

Page content lives in the database, not in code. A LandingPage points at two Place records (origin and destination) and stores everything that changes per language in a django-parler translation table:

class LandingPage(TranslatableModel, SeoFlagsMixin, TimeStampedModel):
    origin = models.ForeignKey(Place, on_delete=models.PROTECT, related_name="landing_origins")
    destination = models.ForeignKey(Place, on_delete=models.PROTECT, related_name="landing_destinations")
    is_active = models.BooleanField(default=True)
    hero_image = models.ImageField(upload_to="landings/", blank=True, null=True)
 
    # meta_title, meta_description, canonical_path... come from the shared helper
    translations = seo_translated_fields(
        slug=models.SlugField(max_length=255),
        h1=models.CharField(max_length=200),
        body=CKEditor5Field(blank=True),
        meta={"unique_together": [("language_code", "slug")]},
    )

The important part is that the slug is translated too. The German page doesn't reuse the English slug; it gets its own, written in German. That gives each language a URL that matches what people in that language actually search for, instead of 15 copies of an English URL. The uniqueness rule is per language: two languages may share a slug, but two pages in the same language can't.

The SEO fields (meta title, meta description, canonical path, OG image) come from one helper shared by the content models, so landings, services, tours and blog posts all expose the same SEO fields in the admin. The non-translated fields (origin, destination, hero image, a "starting from" display price, approximate duration and distance) are entered once and shared by every language.

Translated URL prefixes, not just translated slugs

The path segment in front of the slug is translated as well: the same page lives under /transfer/ in English, /traslados/ in Spanish, /transfert/ in French and /tawsil/ in Arabic. In Next.js this is next-intl's pathnames config in routing.ts, which is the single source of truth.

The catch is that Django needs the same map. The backend builds the alternate URLs that the language switcher and hreflang use, and next-intl's config is a TypeScript constant Django can't import. So the map is mirrored by hand in a Python dict, and a management command (check_url_prefixes) reads routing.ts as text, compares the two, and fails the deploy if they differ. A wrong prefix doesn't fail loudly — it produces a 404 every time someone switches language.

Turkish was deliberately left on the original paths. The old WordPress site had redirect rules pointing at those URLs, and changing them would have turned every one of those redirects into a chain.

hreflang only for languages that really exist

Each page declares its alternates in every language — but only for languages where the page has actually been translated. That sounds obvious, and it's where the first version was wrong.

django-parler has a fallback language. Ask a page that has no Russian translation for its Russian slug and you get the Turkish slug back, so the page would happily announce a Russian alternate URL that doesn't exist. With every language seeded in the test data, nobody noticed. The fix is an explicit has_translation check:

def get_alternate_paths(self, obj):
    paths = {}
    for lang in settings.SITE_LANGUAGES:
        if obj.has_translation(lang):
            slug = obj.safe_translation_getter("slug", language_code=lang)
            if slug:
                paths[lang] = localized_path("transfer", lang, slug)
    return paths

The same alternate_paths object feeds the hreflang tags and the language switcher, so they can't disagree. (Declaring alternates once in the root layout has its own trap — why that's wrong and how to generate them per page.)

Rendering on first request, not at build time

There's no generateStaticParams for landing pages. The route uses ISR with an hourly window:

// app/[locale]/transfer/[slug]/page.tsx
export const revalidate = 3600;
 
export default async function TransferLandingPage({ params }) {
  const { locale, slug } = await params;
  const landing = await fetchLanding(decodeURIComponent(slug), locale);
  if (!landing) notFound();
  // ...
}

Because dynamicParams defaults to true, a slug that wasn't generated at build time is rendered on its first request and then cached. Build time stays the same no matter how many routes the admin adds, and a slug that doesn't exist in the database simply returns a 404.

The decodeURIComponent call is there for a reason. Next.js passes non-Latin slugs (Arabic, Russian) to params URL-encoded, and the fetch helper encodes again — without decoding first, the backend receives a double-encoded slug and every Arabic page is a 404.

Instant updates with a revalidate webhook

An hour is fine as a safety net, but an edit in the admin should show up immediately. When a landing is saved, Django asks Next.js to revalidate the affected paths.

Two details matter here. First, the trigger lives in the admin's save_related hook, which runs after the page and its translations are saved — so the paths are built from the current slugs, not the old ones. Second, the list of paths is larger than you'd think: the landing page in every translated language, plus every language's homepage, because the homepage shows a "popular transfers" list built from these records.

The HTTP call runs in a Celery task so a slow or unreachable frontend never blocks the admin:

@shared_task(ignore_result=True)
def revalidate_landing(paths):
    try:
        resp = requests.post(
            f"{settings.FRONTEND_INTERNAL_URL}/api/revalidate",
            json={"paths": list(paths)},
            headers={"x-revalidate-secret": settings.REVALIDATE_SECRET},
            timeout=5,
        )
        if resp.status_code != 200:
            logger.warning("revalidate failed: %s", resp.status_code)
    except requests.RequestException as exc:
        logger.warning("revalidate request failed: %s", exc)

On the Next.js side it's a plain route handler that checks the shared secret, then calls revalidatePath for each path in the list. Django reaches it over the internal Docker network by service name, so the endpoint isn't something a browser ever calls.

RTL for Arabic and Persian

Two of the 15 languages are right-to-left: Arabic and Persian. Translating text isn't enough; the page direction has to flip too. The RTL languages are kept in one list and the root layout reads it:

// i18n/routing.ts
export const RTL_LOCALES: Locale[] = ["ar", "fa"];
 
// app/[locale]/layout.tsx
const dir = isRtl(locale) ? "rtl" : "ltr";
// <html lang={locale} dir={dir}>

Hard-coding locale === "ar" would have been the shortcut, and it would have left the Persian pages laid out left-to-right. Direction-dependent details then use Tailwind's rtl: variant — for example, "next" arrow icons get rtl:rotate-180 so they point the right way.

Result

Adding a city or route is now an admin task: pick the origin and destination, fill in the slug, heading, text and meta fields for each language, save. The page goes live in every language it was translated into, with its own URL, correct hreflang alternates, and the right text direction, and the homepage lists update with it. No developer and no deploy in the loop.