A functional frontend doesn’t always mean an effective one. That was the insight a student team at Rise Academy gained while migrating Bloom After, a real-world application, from vanilla JavaScript to Next.js with TypeScript. The project wasn’t about fixing broken code—it was about learning to modernize a codebase responsibly before real-world deployment became a necessity.
A Purposeful Migration: Learning Through Practice
The migration wasn’t driven by failure. Bloom After’s original frontend still worked, but it suffered from subtle, long-term maintenance issues:
- Inconsistent data structures returned from the backend, such as using both
_idandidfor the same identifier. - Auth token stored in `localStorage`, which is accessible to client-side scripts and vulnerable to XSS attacks.
- No enforced architectural patterns, leading to inconsistent code style as the team grew.
The goal was educational: to simulate a real-world frontend migration using a project complex enough to reflect industry challenges. Rise Academy’s curriculum emphasizes working on live applications, not toy examples, so the problems encountered were genuine.
Architectural Decisions That Saved Time and Reduced Risk
Before any component was written, the team lead established core principles to avoid pitfalls common in frontend migrations.
Keep Existing Styles Intact
Instead of rewriting CSS, every class name from the legacy frontend was preserved exactly. This decision prevented visual regressions and allowed designers and developers to reference familiar class names throughout the codebase.
/* legacy.css */
.header { display: flex; justify-content: space-between; }
.button-primary { background: #0070f3; }Only the required stylesheets were imported per page, reducing bundle size and avoiding global CSS bloat.
Organize Routes with Logical Groupings
Next.js supports route groups—folders wrapped in parentheses that don’t affect URLs but enable shared layouts. The team used this feature to separate concerns cleanly:
app/
├── (public)/ # Marketing pages with shared public header
├── (admin)/ # Admin dashboard with shared admin header
└── (dashboard)/ # User dashboard with shared sidebarEach group defines its own layout.tsx, ensuring consistent UI elements without conditional logic inside components.
Enforce Data Consistency with TypeScript
The team created normalized TypeScript interfaces that defined the ideal data shape, regardless of backend inconsistencies. A utility function then transformed raw API responses into clean, predictable objects before they reached any component.
// types/course.ts
export interface Course {
id: string;
title: string;
description: string;
createdAt: string;
}
// utils/normalise.ts
export function normaliseCourse(raw: unknown): Course {
const payload = raw as Record<string, unknown>;
return {
id: (payload._id ?? payload.id) as string,
title: payload.title as string,
description: (payload.desc ?? payload.description) ?? payload.dead as string,
createdAt: payload.createdAt as string,
};
}This layer of abstraction became one of the most valuable outcomes of the migration, eliminating guesswork downstream.
Phase One: Building the Foundation
Start Small with the Index Page
The first step was to validate the entire setup—routing, CSS imports, and layout—by rebuilding the index page. Once confirmed stable, the team created two reusable components:
- TeamMembers: A section listing team profiles on public pages.
- SuggestionDrawer: A slide-in panel for user feedback.
These components served as reference implementations for the rest of the team, ensuring consistency in structure and behavior.
Centralize API Calls
Instead of scattering fetch calls across components, the team consolidated all HTTP requests into a single module:
// lib/api.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;
async function request<T>(
method: string,
endpoint: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${BASE_URL}${endpoint}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
});
if (!res.ok) throw new Error(`${method} ${endpoint} failed with status ${res.status}`);
return res.json();
}
export const api = {
get: <T>(endpoint: string) => request<T>("GET", endpoint),
post: <T>(endpoint: string, body: unknown) => request<T>("POST", endpoint, body),
patch: <T>(endpoint: string, body: unknown) => request<T>("PATCH", endpoint, body),
del: <T>(endpoint: string) => request<T>("DELETE", endpoint),
};A dedicated file, lifestyle-api.ts, then scoped requests to the /lifestyle endpoint, giving developers a clean, focused interface without exposing backend complexity.
Week Two: Securing the System
Modernizing Authentication with NextAuth and Cookies
The legacy app stored authentication tokens in localStorage, a practice that risks XSS vulnerabilities and prevents server-side authentication checks. The team migrated to NextAuth.js using HTTP-only cookies for enhanced security.
They implemented custom API routes for login and logout:
// app/api/auth/login/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { email, password } = await request.json();
const response = await fetch(`${process.env.API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
return NextResponse.json({ error: "Login failed" }, { status: response.status });
}
const { token } = await response.json();
cookies().set("auth_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return NextResponse.json({ success: true });
}This setup ensured tokens were inaccessible to JavaScript, reducing exposure to cross-site scripting attacks.
Lessons That Outlast the Project
Migrating a functional frontend isn’t about fixing what’s broken—it’s about preparing for what’s next. The team learned that consistency, type safety, and centralized logic aren’t luxuries in production systems—they’re necessities that prevent technical debt before it accumulates.
As the rise of full-stack frameworks like Next.js continues, more teams will face similar migrations. The principles applied here—preserve what works, refactor what doesn’t, and enforce structure early—can serve as a reliable blueprint.
This project may have started as an academic exercise, but its lessons are already shaping how real applications are built today.
AI summary
Learn how a student team migrated a legacy JavaScript frontend to Next.js with TypeScript, including authentication, route groups, and API centralization strategies.