Today, the frontend starts. Day 91 was the most infrastructure-heavy day on the Next.js side, not because of what's visible, but because of what everything else depends on. The Axios instance with JWT interceptors, the auth context, protected routes, and the login and register pages. By the end of today, a user can register in the browser, be redirected to a dashboard, log out, log back in, and have the access token refreshed invisibly in the background when it expires.
Setting Up the Next.js Project
The setup follows the same principle as the Django backend: get the structure right before writing features. Installing everything at once, organizing folders from day one, and configuring the environment before touching any component.
Next.js is installed with the App Router: the newer file-based routing system that uses the app/ directory instead of the older pages/ directory. Tailwind CSS is set up during installation. Axios is installed separately for API communication.
The folder structure inside src/ is organized into five areas: app/ for pages and routing, components/ for reusable UI, context/ for global state, hooks/ for custom hooks, and services/ for API communication functions. This structure is set up on day one because reorganizing imports mid-project is painful and unnecessary.
The Axios Instance — The Most Important File in the Frontend
Every API call in the entire Next.js app goes through one Axios instance defined in services/api.js. This is the architectural decision that makes everything else clean.
The instance is created with the Django API base URL from an environment variable, NEXT_PUBLIC_API_URL in .env.local. This means switching between the local Django server and the deployed Railway URL is a single environment variable change with no code modifications.
Two interceptors are attached to this instance.
The request interceptor runs before every outgoing request. It reads the access token from localStorage and attaches it as an Authorization header Bearer <token>. If no token exists, the request goes out without an Authorization header, which is correct behavior for public endpoints.
The response interceptor runs after every incoming response. It watches for 401 Unauthorized responses, which means the access token has expired. When it catches one, it automatically posts to the Django token refresh endpoint with the stored refresh token, receives a new access token, stores it in localStorage, updates the Authorization header on the failed request, and retries it. If the refresh also fails, meaning the refresh token has also expired, it clears all tokens from localStorage and redirects to the login page. This entire process is transparent to the user and to every component that makes API calls. No component ever needs to handle token expiry manually.
The Auth Service
services/auth.js contains four functions: register, login, logout, and getCurrentUser, that the rest of the frontend calls when it needs to interact with the authentication API.
Register sends the user's data to the Django register endpoint and stores the returned access and refresh tokens in localStorage along with the user data. Login does the same, but calls the login endpoint. Logout clears all tokens and user data from localStorage. getCurrentUser reads the stored user data from localStorage and returns it. This is how the auth context initializes on page load without needing an API call.
Keeping these functions in a dedicated service file means the auth logic lives in one place. If the token storage mechanism changes. For example, moving from localStorage to httpOnly cookies, only this file needs updating.
The Auth Context
The auth context is what makes the authentication state available to every component in the app without prop drilling. It holds three things: the current user object, a login function, and a logout function.
On mount, it reads the current user from localStorage via the auth service. This is how the app knows whether a user is logged in when they refresh the page: the token and user data persist in localStorage across browser sessions until the user logs out or the tokens expire.
The login function takes the credentials, calls the auth service login function, updates the user state in context, and handles the redirect. The logout function calls the auth service logout function, clears the user state, and redirects to the login page.
The context provider wraps the entire app in layout.js, the root layout file. This means every page and component in the app has access to the auth state through the useAuth hook.
The useAuth Hook
A single-line custom hook that wraps useContext(AuthContext). Every component that needs the current user or the login/logout functions calls useAuth() rather than importing and using useContext directly. This is a small abstraction, but it makes components cleaner and easier to read.
Protected Routes
The ProtectedRoute component wraps every page that requires authentication. It checks for a token in localStorage on mount. If no token exists, it redirects to /login immediately, before the page renders. If a token exists, it renders the children.
The redirect includes the intended URL as a query parameter, so after the user logs in, they return to where they were trying to go. This is the same ?next= pattern from Django's login_required decorator, implemented on the frontend.
In the App Router, protected pages import ProtectedRoute and wrap their content with it. It's a straightforward pattern, one import and one wrapper component per protected page.
The Navbar
The Navbar component reads the current user from the auth context and renders different content based on the auth state. Unauthenticated users see links to the landing page, projects browse page, login, and register. Authenticated users see links to the dashboard, create a project, their profile, and a logout button.
The Navbar is included in the root layout, so it appears on every page automatically. The landing page and auth pages show the minimal version; all other pages show the full authenticated version.
Login and Register Pages
Both pages follow the same pattern: a centered form, controlled inputs, form submission that calls the auth service, error display, and a redirect on success.
The register page collects username, email, password, and confirm password. It validates that passwords match before making the API call. On success, it stores the tokens and redirects to the dashboard.
The login page collects email and password. On success, it stores the tokens and redirects to the next query parameter URL if present, or the dashboard if not.
Both pages check if a user is already authenticated on mount and redirect to the dashboard. If so, logged-in users shouldn't see the login and register pages.
Error handling on both pages displays the error message returned from the Django API directly to the user, "Invalid email or password" or field-specific validation errors from the registration serializer.
Environment Variables
Two environment files are created today.
.env.local for development. NEXT_PUBLIC_API_URL set to http://localhost:8000/api. This is never committed to version control.
.env.local.example showing the required keys without values, committed to the repository, so anyone cloning the project knows what environment variables to set.
NEXT_PUBLIC_ prefix is required for any environment variable that needs to be accessible in the browser. Variables without this prefix are server-side only.
Where Things Stand After Day 91
The authentication foundation is complete on the frontend. Users can register, log in, and log out. The token refresh happens automatically. Protected pages redirect unauthenticated users to login. The Navbar reflects the auth state. The Axios instance handles Authorization headers on every request.
Every subsequent day of frontend work builds on this foundation. The Axios instance means no component ever needs to manually attach tokens. The auth context means no component ever needs to read from localStorage. The ProtectedRoute means no page ever needs to reimplement authentication checks.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)