This is part one in a three-part series:
- Making React and Django play well together
- Making React and Django play well together - the “hybrid app” model
- Making React and Django play well together - the “single page app” model
Making React and Django play well together
Building a frontend with React and create-react-app and the corresponding backend with Django is a popular combination.
Indeed, even though the Node.js ecosystem is growing rapidly, JavaScript backend frameworks still have to catch up with Django or Ruby on Rails in terms of features, quality, and stability. Furthermore, Python provides a more convenient ecosystem for integrating with many third-party systems.
While this post focuses on React to provide concrete examples, the concepts generalize to other frontend frameworks such as Vue, Ember, or Angular.
React and Django are great choices in their respective spaces. Both also attempt to provide a complete framework for building an app:
- With create-react-app,
yarn start
runs a development server on http://localhost:3000; - With Django,
django-admin runserver
does the same on http://localhost:8000.
As a consequence, neither side has a compelling story for working with the other:
- create-react-app points to third-party tutorials for Node, Ruby on Rails, PHP, and C# backends. It is able to proxy API requests in order to avoid CORS issues in development — which sounds like a great way to discover them in production!
- Django provides the staticfiles contrib app. While powerful and flexible, it’s designed for working with files on the filesystem and doesn’t integrate easily with a development server that doesn’t write compiled files to disk.1
Since frameworks fall short when it comes to gluing together frontend and backend, developers tend to come up with kludgy development setups, only to realize they don’t work in production.
Often this leads to shotgun debugging and sometimes to security vulnerabilities. (If you’re concerned, we can audit your app and find such issues.)
Broadly speaking four architectures make sense:2
-
Running the frontend and the backend on distinct origins
In this example, the browser loads the HTML page and static assets from https://app.example.com. It makes cross-origin API requests to https://api.example.com. Most Single-Page Apps (SPA) use this architecture. -
Making the backend serve static files for the frontend
This is Django’s default behavior in development:runserver
serves static assets with a WSGI middleware provided by the staticfiles app. WhiteNoise provides a production-ready implementation of that behavior. -
Making the frontend proxy API requests to the backend
Traditional production deployments of Django use this architecture. With Apache and mod_wsgi, Apache serves static files and proxies other requests to mod_wsgi. With nginx and a WSGI server such as gunicorn, uWSGI, or waitress, nginx serves static files and proxies other requests to the application server. -
Dispatching frontend and backend requests with a reverse proxy
This setup is less common. It happens when a CDN (e.g. CloudFront) serves static assets from an object storage (e.g. S3) and forwards other requests to an application server. For practical purposes, it doesn’t matter very much how static files are served, so options 3 and 4 are equivalent.
Most often:
-
With option 1, the frontend serves the same empty HTML page at every URL. This page loads a JavaScript app which renders the contents of the page, handles navigation, and makes API requests to the backend running on another domain. We’ll refer to this as the “single page app” model.
-
With option 2, 3 or 4, the backend serves HTML pages, often different pages at different URLs or URL prefixes. These pages load JavaScript components that render parts or all of their contents, sometimes handle navigation under a URL prefix, and make API requests to their own domain. We’ll call this the “hybrid app” model — for lack of a well-known term describing this concept.
Although there are many variants, web apps tend to gravitate towards one architecture or the other. Let’s investigate the ramifications of this choice.
Dev / prod parity
Keeping the development setup as close to the production setup as possible minimizes surprises in production.
Backend development relies on Django’s built-in development server while production uses a production server such as Apache, gunicorn, uWSGI, or waitress. The WSGI protocol provides a sufficiently good abstraction to consider there’s dev / prod parity.
Almost every Django project has separate development and production settings modules. Keeping these modules as similar as possible is a good practice for preserving parity at the application level.
It’s more difficult to achieve dev / prod parity on the frontend.
Like all modern toolchains for building frontend apps, create-react-app provides widely different setups for development and production.
The development setup optimizes for reloading the application quickly when the code changes: incremental compilation to memory, HTTP server, hot reloading, etc.
The production setup relies on third-party infrastructure for serving the application and optimizes for application performance at the expense of build time: one-off compilation to disk, minification, hashing, etc.
Perhaps this is why developers sometimes select different architectures for development and production, especially frontend developers who are used to diverging setups.
In my experience, each architecture comes with its own set of challenges. Not only does selecting the same architecture for both development and production increase parity, but it also requires understanding and solving only one set of challenges.
Authentication
There are two mainstream mechanisms for authenticating users: cookies and JWTs. Either authentication mechanism works with any architecture.
Having trouble with authentication isn’t a reason to switch to another architecture. In my experience, broken authentication is usually a symptom of another issue, often a CORS or CSRF issue.
By default, Django’s user authentication system relies on cookie-based sessions. The security model of cookies is well known. Browsers provide first-class support. For example, cookies can expire when the browser closes or after a given date.
When a user logs in with valid credentials, their identity is stored in the session. The browser sends the session cookie which authenticates them automatically with every subsequent HTTP(S) request.
Using Django’s built-in authentication system makes it easier to integrate additional features provided by third-party packages such as two-factor authentication.
JWTs are a popular alternative, perhaps because they remove the need to understand CSRF (more on this below).
Since JWTs are managed at the application level, each application must implement storage, expiry and renewal of JWTs. This is a significant, security-sensitive responsibility.
Even though third-party packages provide these features, the JWT ecosystem is less complete and mature than Django’s historical authentication framework.
JWTs are a good choice for managing authentication in mobile or desktop apps, including those built with web technologies such as Cordova or Electron.3 Users expect permanent authentication in apps, except the most sensitive ones, which alleviates concerns about managing expiry.
If the same backend serves both a web app and mobile or desktop apps, implementing both authentication mechanism can be a good tradeoff. Then the web app relies on cookies and the mobile or desktop apps rely on JWTs.
CORS
The “single page app” model requires setting up CORS because the frontend and the backend run on separate domains.
In our example, the backend server running at https://api.example.com must return the following HTTP headers:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com
Forgetting Access-Control-Allow-Credentials
is a common pitfall and a bad reason to switch from cookies to JWTs.
This is easily achieved with django-cors-headers and the following settings:
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGINS = ['https://app.example.com']
I recommend django-cors-headers over a custom middleware because it provides a nice abstraction with many options and it reduces the risk of making mistakes.
CSRF
CSRF behaves identically in any of the models discussed here. It shouldn’t be a factor in the decision to pick a model. It’s an issue that needs solving, though.
Cookies set on a domain are automatically sent with every HTTP(S) request to that domain. This is known as ambient authority. It makes cookie-based authentication very convenient: you don’t need to do anything special on the client side.
However, if an attacker’s web page makes a cross-domain request to your website, due to ambient authority, the request will carry the user’s authentication. This is a Cross-Site Request Forgery attack.
Django defends against this attack by requiring a CSRF token that must be explicitly added to form submissions and AJAX POST requests. The browsers’ Same-Origin Policy restrictions prevent an attacker from obtaining that token from your site.
There are a few common pitfalls with the approach recommended by Django:
- Sometimes the CSRF token isn’t available in cookies: with the “single-page app” model when the HTML isn’t served by Django, with the “hybrid app” model when the first page doesn’t generate a token, etc.
- Even when there’s a CSRF token available in the DOM, sometimes it’s outdated: when logging in or out doesn’t trigger a full page reload, when logging in or out in another tab, etc.
My recommendation for avoiding these issues is:
- To read the CSRF token from the CSRF cookie before each AJAX request;
- If there’s no CSRF cookie, to make a request to a dedicated endpoint that generates a CSRF token and retry.
Assuming JWTs are stored securely, JWT-based authentication isn’t vulnerable to CSRF attacks because it doesn’t provide ambient authority, unlike cookies.
I’ve seen frustrated developers having a hard time with CSRF switch to JWTs. I don’t think that’s a good reason.
SEO
In some circumstances, Googlebot is able to execute JavaScript and index single-page apps even if the initial HTML is empty.
It’s hard to quantify how client-side rendering affects search ranking compared to traditional server-side rendering but it’s almost certainly a loss. Experiments show that the crawl frequency is lower. Regular HTML pages are a safer bet for the time being when SEO is a concern.
Server-Side Rendering (SSR) — executing frontend JavaScript on the backend — is rarely used with Django because it requires deploying a Node.js stack in addition to the Django stack.
As a consequence, projects that need good SEO tend to prefer the “hybrid app” approach and render HTML in the backend.
Deployment
Running the frontend and the backend separately means you can deploy them separately.
The frontend may be faster to deploy and easier to rollback than the backend, if only because there’s no need to care about database migrations. Being able to deploy the frontend much more often than the backend may increase productivity.
Conversely, deploying the application as a single unit makes it easier to synchronize changes between the frontend and the backend. It reduces concerns about compatibility between arbitrary versions of the frontend and the backend4 and makes it easier to track versions.
The “hybrid app” model is usually implemented by building the frontend, injecting it into the backend’s build process, and deploying the result as a single unit.
The “single page app” model encourages deploying the frontend and the backend separately. The focus is on compatibility rather than synchronized deployments.
Integration
As an application grows, integration testing becomes critical for preventing regressions, especially in agile teams that move fast. End-to-end tests are usually written by scripting actions in a browser, for example with Cypress, and sometimes with a BDD framework.
This creates a challenge when the frontend and the backend are maintained in separate code repositories, which is common in the “single-page app” model. There’s no obvious and meaningful way to run integration tests in CI for all revisions of the frontend and the backend.
The pragmatic solution there is to stabilize the API by adopting a compatibility policy, to test it thoroughly, and to focus the integration tests on preventing regressions in the frontend. In practice this provides a reasonable safety net.
This issue rarely occurs with the “hybrid app” model. Since the backend and frontend are more tightly coupled, they’re almost always maintained in the same code repository. The application can be tested as a whole at each revision.
Collaboration
More generally, this choice should take into account the organization and preferences of your development team.
If the frontend and backend teams are separate and coordinate with an API definition, they’ll be more comfortable with the “single page app” model, where the frontend and the backend are cleanly separated.
If full-stack developers build the application as a whole, they may find that the “hybrid app” model introduces less overhead and keeps processes simple.
If the team is led by frontend developers, they’ll often choose to keep the backend small and separate from the frontend app.
Conversely, if it grows from backend developers, they’ll naturally try to fit the frontend in the backend app like HTML and CSS used to be integrated before frontend frameworks emerged.
Considering all these human and technical factors, I believe there’s no one-size-fits-all solution. Both the “single-page app” and the “hybrid app” model can support great end-user and developer experiences.
Depending on the context, there may be strong reasons to build one or the other.
More often than not, it will be a trade-off between the factors listed above. In all cases, taking full advantage of the benefits and remaining aware of the pitfalls is key to the making the choice a success.
Stay tuned for follow-up posts describing how to implement each model with create-react-app and Django.
Thanks to Loïc Bistuer, Martin De Wulf, Bastien Duret, Rémy Hubscher, Matthias Kestenholz, Sami Lehtinen, Florian Strzelecki, and Jeff Triplett for providing feedback on drafts of this.
-
Such servers didn’t exist in 2011 when the staticfiles app was merged into Django, let alone in 2009 when it was designed. The development of webpack started in 2012. ↩
-
These diagrams are simplified. Actual deployments may include other components such as a load balancer or a CDN. ↩
-
Cookies are available in apps built with web technologies. However, adding the CSRF token to requests isn’t always easy. This makes cookie-based authentication impractical. ↩
-
Even when the frontend and the backend are deployed together, the backend may receive API requests from browsers running an earlier version of the frontend. As a consequence, developers still need a compatibility strategy. ↩