SPA Mini Project — Build a Complete Single-Page Application from Scratch
In this tutorial, you will learn about SPA Mini Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a complete SPA from scratch combining routing, state management, lazy loading, SEO, authentication, security, testing, performance optimization, and deployment into one production-ready application.
What You'll Learn
By the end of this project, you will have built a production-ready SPA that incorporates all the concepts from this tutorial series: client-side routing, state management, lazy loading, SEO strategies, authentication, security hardening, automated testing, performance optimization, and deployment to production hosting.
Why It Matters
Reading tutorials teaches concepts, but building a complete project solidifies your understanding. This capstone project simulates a real-world development process where you must make architectural decisions, integrate multiple concerns, debug issues, and deliver a working application to production.
Real-World Use
This project mirrors the architecture of production SaaS applications like Trello, Asana, or Notion — a dashboard-style SPA with authentication, dynamic content loading, search, and user preferences. Completing this project prepares you to build real-world SPAs for clients or employers.
SPA Capstone Architecture
┌──────────────────────────────────────────────────────────┐
│ Task Manager SPA Architecture │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ App Shell │ │
│ │ ┌──────────┐ ┌───────────┐ ┌────────────────┐ │ │
│ │ │ Navbar │ │ Sidebar │ │ Main Content │ │ │
│ │ │ (Auth) │ │ (Projects)│ │ (Router Outlet) │ │ │
│ │ └──────────┘ └───────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Route: / │ │ Route: /tasks │ │ Route: │ │
│ │ Dashboard │ │ Task Board │ │ /settings │ │
│ │ (lazy loaded) │ │ (lazy loaded) │ │ (lazy loaded)│ │
│ └───────────────┘ └───────────────┘ └──────────────┘ │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐ │
│ │ Auth Provider │ │ Store │ │ Theme │ │
│ │ (Context) │ │ (Zustand) │ │ Provider │ │
│ └───────────────┘ └───────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
Think of this project like building a house after learning carpentry, plumbing, and electrical work separately. Now you put it all together. Each skill you learned — routing, state management, testing — is a trade that contributes to the final structure.
Project Overview
Build a Task Manager SPA with the following features:
- User authentication (login, register, logout) with JWT tokens
- Dashboard showing project overview and recent activity
- Task board with drag-and-drop columns (To Do, In Progress, Done)
- Project detail page with task list and member management
- User settings page with theme preference and profile editing
- Search functionality across tasks and projects
- Responsive design for desktop and mobile
Step 1: Project Setup and Architecture
# Create the project with Vite
npm create vite@latest task-manager -- --template react-ts
cd task-manager
# Install dependencies
npm install react-router-dom zustand @tanstack/react-query
npm install react-helmet-async dompurify
npm install @dnd-kit/core @dnd-kit/sortable
npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D msw @playwright/test
# File structure
# src/
# components/ — Reusable UI components
# pages/ — Route page components (lazy loaded)
# hooks/ — Custom hooks
# store/ — Zustand stores
# api/ — API client functions
# utils/ — Utility functions
# types/ — TypeScript type definitions
# test/ — Test setup and utilities
Step 2: Authentication and Protected Routes
// src/api/auth.js
const API_URL = import.meta.env.VITE_API_URL;
export const authApi = {
async login(email, password) {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include'
});
if (!response.ok) throw new Error('Login failed');
return response.json();
},
async register(name, email, password) {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
credentials: 'include'
});
if (!response.ok) throw new Error('Registration failed');
return response.json();
},
async logout() {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
},
async getSession() {
const response = await fetch(`${API_URL}/auth/session`, {
credentials: 'include'
});
if (!response.ok) return null;
return response.json();
}
};
Step 3: State Management with Zustand
// src/store/taskStore.js
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export const useTaskStore = create(
devtools(
persist(
(set, get) => ({
projects: [],
tasks: {},
selectedProject: null,
setProjects: (projects) => set({ projects }),
addTask: (projectId, task) => set((state) => ({
tasks: {
...state.tasks,
[projectId]: [
...(state.tasks[projectId] || []),
{ ...task, id: crypto.randomUUID(), status: 'todo' }
]
}
})),
moveTask: (projectId, taskId, newStatus) => set((state) => ({
tasks: {
...state.tasks,
[projectId]: state.tasks[projectId].map(t =>
t.id === taskId ? { ...t, status: newStatus } : t
)
}
})),
selectProject: (projectId) => set({ selectedProject: projectId }),
getProjectTasks: (projectId) => {
return get().tasks[projectId] || [];
}
}),
{
name: 'task-manager-ui',
partialize: (state) => ({ projects: state.projects })
}
),
{ name: 'TaskStore' }
)
);
Step 4: Lazy Loaded Routes with SEO Meta Tags
// src/App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const TaskBoard = lazy(() => import('./pages/TaskBoard'));
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'));
const Settings = lazy(() => import('./pages/Settings'));
const Login = lazy(() => import('./pages/Login'));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 2,
refetchOnWindowFocus: false
}
}
});
function App() {
return (
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<Layout>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/projects/:id" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>} />
<Route path="/tasks" element={<ProtectedRoute><TaskBoard /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
</Routes>
</Suspense>
</Layout>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</HelmetProvider>
);
}
Step 5: Testing
// src/test/taskStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { useTaskStore } from '../store/taskStore';
describe('Task Store', () => {
beforeEach(() => {
useTaskStore.setState({
projects: [],
tasks: {},
selectedProject: null
});
});
it('adds a task to a project', () => {
const projectId = 'proj-1';
const task = { title: 'Write tests', description: 'Add unit tests' };
useTaskStore.getState().addTask(projectId, task);
const tasks = useTaskStore.getState().tasks[projectId];
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe('Write tests');
expect(tasks[0].status).toBe('todo');
});
it('moves a task between status columns', () => {
const projectId = 'proj-1';
const task = { title: 'Fix bug' };
useTaskStore.getState().addTask(projectId, task);
const taskId = useTaskStore.getState().tasks[projectId][0].id;
useTaskStore.getState().moveTask(projectId, taskId, 'done');
const moved = useTaskStore.getState().tasks[projectId][0];
expect(moved.status).toBe('done');
});
});
Step 6: Deployment Configuration
# netlify.toml
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Common Mistakes
- Building features in isolation without integration testing. Routes, stores, and API calls interact in complex ways. Always test the complete flow end-to-end.
- Not implementing proper error boundaries. A crash in one lazy-loaded route should not break the entire app. Wrap each route in an ErrorBoundary.
- Forgetting to remove console.log and debug code in production. Use build-time removal with terser or Vite's drop_console option.
- Skipping Accessibility. Keyboard navigation, screen reader support, and focus management are essential for production apps.
- No loading or error states for lazy-loaded routes. The Suspense fallback and error boundaries must handle network failures gracefully.
Practice Questions
- What architectural decisions did you make for this project, and why?
- How do you handle authentication state across page refreshes?
- What testing Strategy did you implement and why?
- How does lazy loading improve the initial load time of this application?
- What security measures did you implement and why?
Challenge: Extend the Task Manager SPA with: real-time collaboration using WebSockets (broadcast task moves to other users), offline support with a service worker that caches the app shell and syncs tasks when online, dark mode with CSS custom properties persisted to localStorage, keyboard shortcuts for common actions (n, t, / for new task, search), and a performance budget that fails the CI build if bundle size exceeds 300 KB.
FAQ
Mini Project
The project you just built IS the mini project. Extend it with: a real-time activity feed using Server-Sent Events, file attachments for tasks using signed upload URLs, a calendar view showing task deadlines, email notifications when tasks are assigned to you, and a public API with Rate Limiting for third-party integrations.
What's Next
You have completed the SPA tutorial series. Explore related topics: Server-Side Rendering to learn how SSR addresses SPA limitations, or Progressive Web Apps to make your SPA installable and offline-capable.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro