Django URLs reversing in JS code

Modern frontend usually needs to interact with the API in order to get some data, which can be easily achieved with ready-made JS libraries, axios for instance:

import axios from 'axios';

axios.get('api/products/in-stock/').then(function(response) {  // Get all products which are in stock
    // Process received data (from `response.data`)
}).catch(function(error) {
   window.location.href = 'error_page/';  // Redirect to 'error_page' in case of error
});

Our technical debt will grow at a monstrous rate if we don't separate the moving pieces from the rest of your work. Because of this we use constants and configuration files where appropriate. And URLs is one of the things that should not be hardcoded as well — especially when you need to add params to your URLs, or they could simply change (API version changed etc).

E.g. for 'betterliving/<category_slug>/<entry_pk>/' in JS we will have:

let url = 'betterliving/' + categorySlug + '/' + entryId + '/';

// or for ES2015+
let url = `betterliving/${categorySlug}/${entryId}/`;

Django gives you the option to name your URLs in case you need to refer to them from your code, or your templates. This is useful and good practice because you avoid hard-coding urls in your code or inside your templates. Even if you change the actual url, you don't have to change anything else, since you will refer to them by name.

If you have "hybrid application", you can reverse Django URLConf in the template and assign to the JS variable:

<script>
    let url = "{% url 'betterliving_get_house' category_slug='house' entry_pk=12 %}";
</script>

And it will be very useful to have the same opportunity to work with URL names and not with raw URLs in js code as well. And for this you can find couple options.

1. Crossing

Crossing is JavaScript URL utility library, developed by prosperous LincolnLoop, that aims to help you manage and generate dynamic urls.

let Crossing = require('crossing');

let urls = new Crossing();

// Load your url list
urls.load({
    'api:products_in_stock': 'api/products/in-stock/',
    'betterliving_get_house': 'betterliving/<category_slug>/<entry_pk>/',
    'error_page': 'error_page/',
  });

// Get a url - similar to Django’s `reverse`
const path = urls.get('betterliving_get_house', {'category_slug': 'house', 'entry_pk': 12});

Although the library it does not integrate with Django directly, it's small and lightweight and will work perfectly if you need just a couple of URLs to reverse and it does not bring additional Python dependency.

2. Django-js-url

Django-js-url is a Django application, that generates Javascript file with the Django URLconfs, similar to Django-js-reverse, but not all at once — only those, which included to the setting:

JS_URLS = (
    'admin',
    'blog:article_list',
    'blog:article_detail',
)

Then, URLs included to the setting, will be available through the window.reverse call:

const url = window.reverse('home');

3. Django JS Reverse

Another option (and is our app of choice) is Django JS Reverse. Django JS Reverse is a small django app that makes url handling of named urls in javascript easy and non-annoying. Generates a list (js file) of available urls with management command ./manage.py collectstatic_js_reverse.

Urls.betterliving_get_house('house', 12)
// or
// This is for cases where url names are not valid javascript identifiers ([$A-Z_][-Z_$]*)
Url['betterliving_get_house']('house', 12)

And you will get: `/betterliving/house/12/`

URLs and internationalization

Everything becomes more interesting when you start working with multilingual platforms. Django allows to create locale-prefixed URLconfs within the i18n_patterns.

When you create js file with management command for sites with multiple languages for URLs which wrapped with `i18n_patterns` will be created incorrect URLs. But when it comes to generate JS URLconfs using django-js-reverse, there's no active locale during management command execution, which could be used in the i18_patterns, thus we get `None/catalogue` instead of `en/catalogue`. This is not the issue in Django JS reverse, but rather specifics of Django implementation of i18n URLs. If you just want to avoid improper URLs in the generated JS module, you can work it around and disable internationalization within the separate Django settings module:

# settings_for_django_js_reverse.py
from settings.base import *

USE_I18N = False  # Just take off i18n

This would work if URLconfs you want to reverse, are not locale-prefixed, for instance, API endpoints.

Another solution was provided by @vladlep in the django-js-reverse/issues/50, it determines locale in JS and injects it into the URLconf:

var language = window.preferred_language;
if(!language){
    language= document.getElementsByTagName('html')[0].getAttribute('lang');
}
if (!url.startsWith(language) && language){
    url = language + url.substring(url.indexOf('/'));
}

We also came up with out own solution, which firstly converts locale prefix into the URLconf parameter:

"""django_js_reverse/core.py"""
def generate_url_pattern(namespace_path, pattern):
    """
    Builds correct pattern for sites with multiple languages.
    Returns tuple (<url_pattern>, <list_of_args>)
    E.g. `pattern`:
        ('products/checkout/%(basket_pk)s/', ['basket_pk'])
    If `namespace_path == ''`, will be returned as is.
    E.g. `pattern` when url wrapped with `i18n_patterns`:
        ('None/products/checkout/%(basket_pk)s/', ['basket_pk'])
    If `namespace_path == ''`, will be returned:
        ('%(locale)s/products/checkout/%(basket_pk)s/', ['basket_pk'])
        Then `locale` will be correctly filled in js code.
    """

    url, args = pattern
    if url.startswith('None'):
        url = url.replace('None', '%(locale)s', 1)
    return '{0}{1}'.format(namespace_path, url), args

Respectively, in the JS URLconf template, we detect current locale, respecting allowed languages and populate locale as generic parameters (ID, slug etc):

// django_js_reverse/templates/django_js_reverse/urls_js.tpl
if (url.indexOf("%(locale)s") !== -1) {
    // Populate url with locale
    url = url.replace("%(locale)s", detected_locale());
}

function detected_locale() {
    var allowed_language_codes = {{ allowed_language_codes|safe }};
    var locale = window.navigator.language.substr(0, 2); // Shorten strings to use two chars (E.g. "en-US" -> "en")
    if (locale && allowed_language_codes && allowed_language_codes.indexOf(locale) !== -1) {
        return locale;
    }
    return "{{ default_language_code }}";  // `LANGUAGE_CODE` in Django settings
}

Finally, we added tests and prepared PR — https://github.com/ierror/django-js-reverse/pull/68.