Building a Seamless JWT Onboarding Flow with React Router v7 and Django

Published: (March 17, 2026 at 06:47 AM EDT)
6 min read
Source: Dev.to

Source: Dev.to

![Cover image for Building a Seamless JWT Onboarding Flow with React Router v7 and Django](https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzty2n3i1bstxvwvqx28w.png)

[![Vicente G. Reyes](https://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F126345%2F84bad9a2-d302-4943-8934-6c27a497daa1.png)](https://dev.to/highcenburg)

---

Authentication and onboarding are often the highest‑friction points in a new user's journey. If the process is clunky or requires too many redirects, users drop off. Recently, I set out to build a streamlined **JWT authentication and onboarding flow** using a modern stack:

* **React Router v7 (with Vite)**
* **TypeScript**
* **Django REST Framework (DRF)**

In this article, I'll walk through the architecture, the backend implementation, and how to handle protected routes securely on the client side.

## The Architecture Stack

**Backend**

* Django
* Django REST Framework (DRF)
* `djangorestframework-simplejwt`

**Frontend**

* React (via Vite) with React Router v7
* TypeScript

**Styling**

* Tailwind CSS

**State Management**

* React Context API for lightweight auth state handling

## The Goal

1. A user registers.  
2. The user logs in and receives a short‑lived access token and a refresh token.  
3. The backend knows if the user has completed their profile setup (`is_onboarded`).  
4. If they haven't onboarded, the backend middleware blocks API requests, and the frontend automatically reroutes them to the `/onboarding` page.  
5. Once onboarded, new tokens are issued with the updated status, and the user gains full access to the application (`/`).

---

## 1. Setting up the Django Backend

### The Custom User Model

We need to track whether a user has finished setting up their profile. Extending Django's `AbstractUser` gives us a convenient place to store this flag.

```python
# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    is_onboarded = models.BooleanField(default=False)

Customizing the JWT Payload

SimpleJWT only includes user_id by default. To avoid an extra API call, we inject the user’s name and onboarding status directly into the token payload.

# users/serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

class MyTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        # Inject custom claims into the JWT payload
        token['is_onboarded'] = user.is_onboarded
        token['username'] = user.username
        return token

Enforcing Onboarding with Middleware

A custom Django middleware blocks requests from authenticated users who haven’t completed onboarding.

# users/middleware.py
from django.http import JsonResponse
from django.urls import reverse

class OnboardingMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Endpoints that should be reachable without onboarding
        exempt_urls = [
            reverse('token_obtain_pair'),
            '/api/onboard/submit/',
            '/api/register/',
        ]

        if request.path in exempt_urls or request.path.startswith('/admin/'):
            return self.get_response(request)

        user = request.user
        # Block access if authenticated but not onboarded
        if user.is_authenticated and not user.is_onboarded:
            return JsonResponse(
                {
                    "error": "ONBOARDING_REQUIRED",
                    "message": "Please complete onboarding before accessing this resource."
                },
                status=403,
            )

        return self.get_response(request)

Note: Even if a user tries to bypass the frontend routing, the API will refuse to serve them data.

Refreshing Tokens upon Onboarding

When a user completes the onboarding form, we need to issue fresh tokens that reflect the new is_onboarded state.

# users/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions
from .serializers import MyTokenObtainPairSerializer

class OnboardSubmitView(APIView):
    permission_classes = (permissions.IsAuthenticated,)

    def post(self, request):
        user = request.user
        if user.is_onboarded:
            return Response(
                {"detail": "Already onboarded."},
                status=400,
            )

        user.is_onboarded = True
        user.save()

        # Generate fresh tokens reflecting the new state
        refresh = MyTokenObtainPairSerializer.get_token(user)

        return Response(
            {
                "detail": "Onboarding complete.",
                "is_onboarded": True,
                "access": str(refresh.access_token),
                "refresh": str(refresh),
            },
            status=status.HTTP_200_OK,
        )

2. Setting up the React Frontend

We created the frontend with the Vite‑powered React Router v7 template.

Bypassing CORS locally

During development, Vite can proxy API calls to the Django dev server, eliminating CORS headaches.

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  // plugins...
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8000",
        changeOrigin: true,
        secure: false,
      },
    },
  },
});

Continue with the rest of the React implementation (router setup, auth context, protected route handling, etc.) as needed.

// proxy.conf.js
module.exports = () => ({
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        pathRewrite: { '^/api': '' },
      },
      '/static': {
        target: 'http://localhost:8000',
        changeOrigin: true,
      },
    },
  },
});

Global Authentication Context

We use React Context to provide user data (token, user payload, login, logout) globally.

A critical detail: Server‑Side Rendering (SSR). React Router v7 utilizes SSR by default when hydrating the app. If you try to read localStorage.getItem('access_token') immediately on the server, Node.js will crash with a ReferenceError: localStorage is not defined.

We fix this by waiting for hydration on the client using a simple useEffect:

// app/contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';

// ... interfaces and parseJwt helper ...

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState(null);
  const [isClient, setIsClient] = useState(false);

  // Safe SSR hydration
  useEffect(() => {
    setIsClient(true);
    setToken(typeof window !== 'undefined' ? localStorage.getItem('access_token') : null);
  }, []);

  const user = token ? (parseJwt(token) as UserPayload) : null;

  useEffect(() => {
    if (token) localStorage.setItem('access_token', token);
    else localStorage.removeItem('access_token');
  }, [token]);

  const login = (access: string, refresh: string) => {
    localStorage.setItem('refresh_token', refresh);
    setToken(access);
  };

  const logout = () => {
    localStorage.removeItem('refresh_token');
    setToken(null);
  };

  // Prevent hydration mismatch
  if (!isClient) return null;

  return (
    
      {children}
    
  );
}

Route Protection and Redirection

With our context in place, protecting routes and enforcing onboarding becomes incredibly simple. We intercept users in a useEffect on our page components.

Here is what our protected Home Dashboard looks like:

// app/routes/home.tsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../contexts/AuthContext';

export default function Home() {
  const { token, user, logout } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!token) {
      navigate('/login'); // Kick out unauthenticated users
    } else if (user && !user.is_onboarded) {
      navigate('/onboarding'); // Kick out un‑onboarded users
    }
  }, [token, user, navigate]);

  // Don't render until verified to prevent brief flashes of content
  if (!user || !user.is_onboarded) return null;

  return (
    
      
## Welcome to your portal, {user.username}!

    
  );
}

When the user is pushed to /onboarding, they hit our submit button. The API returns the new tokens, we update the context, and React Router instantly pushes them back to / seamlessly!

// Inside Onboarding component submit handler
const handleComplete = async () => {
  const res = await fetch('/api/onboard/submit/', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  });
  if (res.ok) {
    const data = await res.json();
    login(data.access, data.refresh); // Instantly updates global context
    navigate('/');                    // Redirect to dashboard
  }
};

Conclusion

By embedding the is_onboarded claim directly inside the JWT, we eliminate the need for the frontend to constantly ping the database to check a user’s status. Coupling this tightly with Django middleware ensures backend security, while utilizing React Context provides snappy, seamless redirects on the frontend.

Building auth doesn’t have to be painful—​with the right architecture, it can be safe, scalable, and a great experience for the end user.

0 views
Back to Blog

Related posts

Read more »