Skip to content

SPA Mini Project — Build a Complete Single-Page Application from Scratch

DodaTech Updated 2026-06-28 8 min read

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

  1. Building features in isolation without integration testing. Routes, stores, and API calls interact in complex ways. Always test the complete flow end-to-end.
  2. Not implementing proper error boundaries. A crash in one lazy-loaded route should not break the entire app. Wrap each route in an ErrorBoundary.
  3. Forgetting to remove console.log and debug code in production. Use build-time removal with terser or Vite's drop_console option.
  4. Skipping Accessibility. Keyboard navigation, screen reader support, and focus management are essential for production apps.
  5. No loading or error states for lazy-loaded routes. The Suspense fallback and error boundaries must handle network failures gracefully.

Practice Questions

  1. What architectural decisions did you make for this project, and why?
  2. How do you handle authentication state across page refreshes?
  3. What testing Strategy did you implement and why?
  4. How does lazy loading improve the initial load time of this application?
  5. 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

What is the best way to learn SPA development?

Build projects. Start with a simple app like a todo list, then gradually add complexity: routing, state management, authentication, testing, and deployment. Each project teaches new concepts.

Should I use TypeScript for SPAs?

Yes. TypeScript catches type errors at build time, provides better IDE support, and makes code more maintainable. Every production SPA should use TypeScript.

How do I decide between React, Vue, or Angular?

React has the largest ecosystem and most job opportunities. Vue is easier to learn and great for smaller projects. Angular is best for enterprise applications with large teams.

What is the most important concept in this series?

Understanding the SPA tradeoff: SPAs provide a better user experience after load but require more engineering effort for SEO, performance, and security. Choose SPA when interactivity matters more than initial load.

How do I keep learning after this project?

Contribute to open-source SPAs, read framework documentation, follow the SPA ecosystem (Next.js, Remix, Nuxt), and build more complex projects with real-time features, offline support, and Progressive Web App capabilities.

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