My Story Building an Unacademy Clone with React JS
I’ve always been inspired by edtech companies like Unacademy that are making education more accessible. When the pandemic hit and online learning became the norm, I saw an opportunity to build my own learning platform.
In this blog, I will take you through my journey of replicating key Unacademy features to build an MVP learning app. You can also checkout Zipprr Page https://zipprr.com/unacademy-clone/
Choosing Technologies
As a front-end developer, React was my obvious choice for building the user interfaces.
For the back-end, I settled on Node, Express and MongoDB.
Get Zipprr’s stories in your inbox
Join Medium for free to get updates from this writer.
I took some time to research various tech stack possibilities:
- Frontend: React for its maturity and popularity. Vue and Svelte were other options.
- Backend: Node/Express as I was most familiar with JavaScript backend. Also considered Python Django.
- Database: MongoDB as document-oriented database suited the data. Also explored PostgreSQL, MySQL.
- Hosting: Heroku for initial hosting. Planned AWS for production.
- Payment: Stripe for simplicity of checking out and subscriptions.
- Video: YouTube APIs for video playback and hosting content.
To evaluate the options programmatically:
// frameworks.js
const frameworks = [
{
name: 'React',
pros: ['Large community', 'Latest features'],
cons: ['Library not framework']
},
{
name: 'Vue',
pros: ['Smaller size', 'Rising in popularity'],
cons: ['More limited resources']
}
]
function scoreFramework(framework) {
let score = 0;
framework.pros.forEach(pro => {
score++
})
framework.cons.forEach(con => {
score--
})
return score
}
const bestFramework = frameworks.sort((a, b) =>
scoreFramework(b) - scoreFramework(a)
)[0]This analysis approach helped select technologies well-suited for the requirements and my skillset.
Designing the Homepage
To design the homepage, I first created the basic page layout and UI components in React:
// Home.jsx
function Home() {
return (
<div>
<Hero Banner />
<CourseCategories />
<FeaturedInstructors />
<PopularCourses />
<FreeCourseCTA />
</div>
)
}I then built out each component with functionality:
// HeroBanner.jsx
function HeroBanner() {
return (
<div className="hero">
<Image src="/hero-img.png" />
<Button>Get Started</Button>
</div>
)
}Styling was added using Styled Components:
const Hero = styled.div`
display: flex;
align-items: center;
background: url(https://medium.com/$%7B(props) => props.background});After linking to mock data, the basic homepage structure was complete. I then added responsive design using media queries.
Building the Course Catalog
To build the course catalog, I first created the Course model:
// models/Course.js
const courseSchema = new Schema({
title: String,
//...other fields
})
export default model('Course', courseSchema)I built API routes to retrieve courses:
// courses.controller.js
export const getCourses = async (req, res) => {
try {
const courses = await Course.find();
res.json(courses);
} catch (err) {
res.status(500).json({ message: err.message });
}
}Then displayed them in React:
// Courses.jsx
function Courses() {
const [courses, setCourses] = useState([]);
useEffect(() => {
const fetchCourses = async () => {
const res = await axios.get('/api/courses');
setCourses(res.data);
}
fetchCourses();
},[])
return (
<div>
{courses.map(course => (
<CourseItem key={course._id} course={course}/>
))}
</div>
)
}This populated real courses on the catalog page.
Adding Search and Filters
I enabled search using MongoDB text search:
// courses.controller.js
export const searchCourses = async (req, res) => {
const { keyword } = req.query;
try {
const courses = await Course.find({
$text: { $search: keyword }
});
res.json(courses);
} catch (err) {
res.status(500).json({ message: err.message });
}
}Filters were added with React Hook Form:
// CourseFilter.jsx
function CourseFilter() {
const { register, handleSubmit } = useForm();
const onSubmit = async (data) => {
// call api with filter data
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field}>
// categories
</Select>
)}
/>
<Button type="submit">
Filter
</Button>
</form>
)
}This allowed powerful discovery of relevant courses.
Designing the Instructor Experience
I created API routes for authentication:
// auth.controller.js
export const register = async (req, res) => {
try {
// Create user from request
const user = await User.create(req.body);
// Generate JWT
const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET);
res.status(200).json({ token });
} catch (err) {
res.status(400).json({ message: err.message });
}
}And profile forms in React:
// ProfileForm.jsx
function ProfileForm() {
const { register, handleSubmit } = useForm();
const onSubmit = (data) => {
// call API to update profile
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="name"
control={control}
render={({ field }) => (
<Input {...field} />
)}
/>
<Button type="submit">
Update
</Button>
</form>
)
}This allowed instructors to conveniently onboard.
Building the Instructor Dashboard
The dashboard had a common layout:
// Dashboard.jsx
function Dashboard() {
return (
<Layout>
<Sidebar />
<DashboardContent />
</Layout>
)
}Individual pages used API data:
// MyCourses.jsx
function MyCourses() {
const [courses, setCourses] = useState([])
useEffect(() => {
const fetchCourses = async () => {
const res = await api.getCourses();
setCourses(res.data);
}
fetchCourses();
},[])
return (
// display list of courses
)
}Navigation handled routing pages. This centralized instructor tools.
Uploading Course Content
The uploader used Amplify:
// S3.js
import Amplify, { Storage } from 'aws-amplify';
Amplify.configure({
// AWS config
});
export const uploadFile = async (file) => {
try {
const filename = file.name;
const stored = await Storage.put(filename, file);
return stored;
} catch (err) {
console.log('Error uploading file: ', err);
}
}A React component integrated it:
// FileUploader.jsx
const FileUploader = () => {
const handleUpload = async (e) => {
const file = e.target.files[0];
await uploadFile(file);
}
return (
<div>
<input type="file" onChange={handleUpload}/>
</div>
)
}This empowered instructors to efficiently upload content.
Managing Lessons and Materials
The lessons were stored in MongoDB:
// models/Course.js
const courseSchema = new Schema({
lessons: [{
title: String,
content: String,
//...
}]
})A lesson editor allowed CRUD:
// LessonEditor.jsx
const LessonEditor = ({ lesson }) => {
const { register, handleSubmit } = useForm();
const onSubmit = (data) => {
// API call to update lesson
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="title"
control={control}
//...
/>
<Button type="submit">
Update
</Button>
</form>
)
}Reorder used array methods:
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
}Implementing Live Classes
The Jitsi API was initialized:
// jitsi.service.js
import JitsiMeetSDK from "../../node_modules/jitsi-meet";
const domain = 'domain';
const options = {
//...
}
export const jitsi = new JitsiMeetSDK(options);Classes were scheduled:
// ScheduleClass.js
const schedule = async () => {
// Create class doc
const classDoc = await Class.create({
//...
});
// Generate token
const token = jitsi.generateToken(meeting);
// Return class & token
res.send({class, token});
}Student Enrollment Experience
Enrollment used React Context:
// CartProvider.js
export const CartContext = createContext();
function CartProvider({children}) {
const [cart, setCart] = useState([]);
const addToCart = (course) => {
setCart([...cart, course]);
}
return (
<CartContext.Provider value={{cart, addToCart}}>
{children}
</CartContext.Provider>
)
}Checkout integrated Stripe payments:
// Checkout.js
const { cart, removeFromCart } = useContext(CartContext);
const handlePayment = async () => {
// Create payment intent
const paymentIntent = await createPaymentIntent();
// Confirm card payment
const { error } = await stripe.confirmCardPayment(
paymentIntent.client_secret
);
if(!error) {
// TODO: process payment
}
}Architecture Discussion
Key services were created:
// courses.service.js
const courses = async (req, res) => {
try {
const { id } = req.params;
if(id) {
// get single course
} else {
// get all courses
}
res.send()
} catch (err) {
// error handling
}
}
module.exports = {
courses
}A gateway handled routing:
// gateway.js
import { coursesService } from './courses.service';
app.get('/api/courses/:id?', (req, res) => {
coursesService.courses(req, res);
});This established a scalable platform architecture.
Testing and Launching the MVP
Before launch, extensive testing was crucial to ensure quality. Some of the key testing strategies implemented were:
Unit Testing
I used Jest to unit test smaller pieces of code in isolation:
// course.service.test.js
test('GET returns courses', async () => {
// Mock course database
courses.find = jest.fn().mockResolvedValue([
// dummy course data
]);
await coursesService.getCourses();
expect(courses.find).toHaveBeenCalled();
expect(response).toEqual([/* course data */]);
});Integration Testing
Integration tests used Supertest to test API route handler functions:
// courses.controller.test.js
test('GET /api/courses', async () => {
const response = await request(app)
.get('/api/courses')
expect(response.status).toBe(200);
expect(response.body).toEqual([/* course data */]);
});UI Testing
Cypress ran frontend tests in a real browser:
// courses.cy.js
describe('Courses Page', () => {
it('fetches courses on mount', () => {
cy.intercept('GET','/api/courses', {
// fixture data
}).as('getCourses');
cy.visit('/courses');
cy.wait('@getCourses');
cy.get('.courses').should('have.length', 3);
});
});Load Testing
I used Loader.io to test API response under heavy load.
Security Auditing
Snyk automated security checks found any vulnerabilities in dependencies.
Manual testing cross-checked all features across browsers/devices. Feedback from closed beta helped correct critical bugs before public launch. Monitoring with DataDog ensured stability post-launch.
Gradual rollout helped manage traffic while minimizing impact of any remaining issues for users. Overall, thorough testing helped deliver a robust and performant MVP.









