Skip to content

Alpine.js Project — Build a Complete Real-World Application

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you'll learn to build a complete Alpine.js project. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Build a complete real-world Alpine.js task management application that combines x-data, x-model, x-show, x-for, x-transition, and plugins into a fully functional single-page app.

What You'll Learn

By the end of this tutorial, you'll have built a task manager with add/edit/delete, search filtering, persistence via $persist, modal dialogs with focus trapping, and smooth animations.

Why It Matters

Reading tutorials teaches syntax. Building a complete project teaches architecture: how components interact, how state flows, and how to organize Alpine code for maintainability and performance.

Real-World Use

DodaZIP's internal project tracker started as a single Alpine Prototype similar to this project. The patterns you'll learn here scale to real production applications used by thousands of users.

Where This Fits in Your Learning Path

flowchart LR
    A["Plugins & Magics"] --> B["**Alpine Project**"]
    B --> C["Production Alpine Apps"]
    style B fill:#f97316,stroke:#c2410c,color:#fff
    style A fill:#e5e7eb,stroke:#9ca3af,color:#374151
    style C fill:#22c55e,stroke:#16a34a,color:#fff

Project Overview

We'll build a Task Manager with these features:

  • Add new tasks with title, priority, and category
  • Edit existing tasks in a modal
  • Mark tasks as complete
  • Filter by status (all/active/completed)
  • Search by keyword
  • Sort by priority or date
  • Persist data across page loads
  • Smooth animations for add/remove

Step 1: Set Up the HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Alpine Task Manager</title>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
</head>
<body>
  <div x-data="taskManager()" class="max-w-2xl mx-auto p-6">
    <!-- Components will go here -->
  </div>
  <script>
    function taskManager() {
      return {
        // State and methods will go here
      }
    }
  </script>
</body>
</html>

Step 2: Define the Component State and Methods

function taskManager() {
  return {
    tasks: Alpine.$persist([]),
    newTask: { title: '', priority: 'medium', category: 'general' },
    search: '',
    filter: 'all',
    sortBy: 'date',
    editingTask: null,
    showModal: false,
    nextId: Alpine.$persist(1),

    addTask() {
      if (!this.newTask.title.trim()) return
      this.tasks.push({
        id: this.nextId++,
        title: this.newTask.title,
        priority: this.newTask.priority,
        category: this.newTask.category,
        done: false,
        createdAt: new Date().toISOString()
      })
      this.newTask = { title: '', priority: 'medium', category: 'general' }
    },

    editTask(task) {
      this.editingTask = { ...task }
      this.showModal = true
    },

    saveEdit() {
      const idx = this.tasks.findIndex(t => t.id === this.editingTask.id)
      if (idx !== -1) this.tasks[idx] = { ...this.editingTask }
      this.showModal = false
      this.editingTask = null
    },

    deleteTask(id) {
      this.tasks = this.tasks.filter(t => t.id !== id)
    },

    toggleTask(id) {
      const task = this.tasks.find(t => t.id === id)
      if (task) task.done = !task.done
    },

    get filteredTasks() {
      let result = [...this.tasks]
      if (this.filter === 'active') result = result.filter(t => !t.done)
      if (this.filter === 'completed') result = result.filter(t => t.done)
      if (this.search) {
        const q = this.search.toLowerCase()
        result = result.filter(t => t.title.toLowerCase().includes(q))
      }
      if (this.sortBy === 'priority') {
        const order = { high: 0, medium: 1, low: 2 }
        result.sort((a, b) => order[a.priority] - order[b.priority])
      } else {
        result.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
      }
      return result
    },

    get stats() {
      return {
        total: this.tasks.length,
        active: this.tasks.filter(t => !t.done).length,
        completed: this.tasks.filter(t => t.done).length
      }
    }
  }
}

Expected behavior: The task manager maintains state with persistence. Methods handle CRUD operations. FilteredTasks is a computed getter for display.

Step 3: Build the Add Task Form

<form @submit.prevent="addTask" class="flex gap-2 mb-6">
  <input x-model="newTask.title" placeholder="Task title" required class="flex-1 p-2 border rounded">
  <select x-model="newTask.priority" class="p-2 border rounded">
    <option value="high">High</option>
    <option value="medium">Medium</option>
    <option value="low">Low</option>
  </select>
  <select x-model="newTask.category" class="p-2 border rounded">
    <option value="general">General</option>
    <option value="work">Work</option>
    <option value="personal">Personal</option>
  </select>
  <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded">Add</button>
</form>

Expected output: A form with a text input, two selects, and a submit button. Submitting adds a task to the list.

<div class="flex gap-2 mb-4">
  <input x-model="search" placeholder="Search tasks..." class="flex-1 p-2 border rounded">
  <select x-model="filter" class="p-2 border rounded">
    <option value="all">All</option>
    <option value="active">Active</option>
    <option value="completed">Completed</option>
  </select>
  <select x-model="sortBy" class="p-2 border rounded">
    <option value="date">Date</option>
    <option value="priority">Priority</option>
  </select>
</div>

Expected output: Controls for searching, filtering by status, and sorting. The task list updates reactively.

Step 5: Build the Task List

<div class="space-y-2">
  <template x-for="task in filteredTasks" :key="task.id">
    <div x-show="true" x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0 transform scale-95"
         x-transition:enter-end="opacity-100 transform scale-100"
         class="flex items-center gap-3 p-3 bg-white rounded shadow-sm border">
      <input type="checkbox" :checked="task.done" @change="toggleTask(task.id)" class="w-5 h-5">
      <div class="flex-1">
        <span x-text="task.title" :class="task.done ? 'line-through text-gray-400' : ''" class="font-medium"></span>
        <div class="flex gap-2 text-xs text-gray-500 mt-1">
          <span :class="{'text-red-500': task.priority === 'high', 'text-yellow-500': task.priority === 'medium', 'text-green-500': task.priority === 'low'}"
                x-text="task.priority"></span>
          <span x-text="task.category"></span>
        </div>
      </div>
      <button @click="editTask(task)" class="text-sm text-blue-500 hover:underline">Edit</button>
      <button @click="deleteTask(task.id)" class="text-sm text-red-500 hover:underline">Delete</button>
    </div>
  </template>
  <p x-show="filteredTasks.length === 0" class="text-gray-400 text-center py-8">No tasks found</p>
</div>

Expected output: A list of tasks with checkboxes, priority colors, edit and delete buttons. Adding, editing, or deleting tasks animates smoothly.

Step 6: Build the Stats Bar

<div class="mt-6 flex gap-4 text-sm text-gray-500">
  <span x-text="`Total: ${stats.total}`"></span>
  <span x-text="`Active: ${stats.active}`"></span>
  <span x-text="`Completed: ${stats.completed}`"></span>
</div>

Expected output: A stats bar showing task counts that update in real time.

Step 7: Build the Edit Modal

<div x-show="showModal" x-trap.noscroll="showModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
     @keydown.escape.window="showModal = false">
  <div @click.outside="showModal = false" class="bg-white p-6 rounded-lg w-full max-w-md">
    <h2 class="text-lg font-bold mb-4">Edit Task</h2>
    <template x-if="editingTask">
      <div class="space-y-3">
        <input x-model="editingTask.title" class="w-full p-2 border rounded" placeholder="Task title">
        <select x-model="editingTask.priority" class="w-full p-2 border rounded">
          <option value="high">High</option>
          <option value="medium">Medium</option>
          <option value="low">Low</option>
        </select>
        <select x-model="editingTask.category" class="w-full p-2 border rounded">
          <option value="general">General</option>
          <option value="work">Work</option>
          <option value="personal">Personal</option>
        </select>
        <div class="flex gap-2 pt-2">
          <button @click="saveEdit" class="px-4 py-2 bg-blue-500 text-white rounded">Save</button>
          <button @click="showModal = false" class="px-4 py-2 bg-gray-200 rounded">Cancel</button>
        </div>
      </div>
    </template>
  </div>
</div>

Expected output: Clicking Edit opens a modal with pre-filled values. Focus is trapped inside. Escape or clicking outside closes it. Save updates the task.

Complete Application

The full application combines all steps above. Paste them together into a single HTML file and open it in a browser. The task manager works with persistence across page refreshes, smooth animations, and full CRUD functionality.

Common Mistakes

1. Not using $persist for the tasks array

Without $persist, all tasks disappear on page refresh. Always persist user data.

2. Forgetting to use :key in x-for

Without unique keys, Alpine may incorrectly reuse DOM nodes when the list changes, causing visual bugs.

3. Not handling the empty state

When no tasks match the filter, show a helpful message instead of a blank page.

4. Editing the task object directly instead of cloning

Always clone the task for editing (using spread operator) so canceling doesn't modify the original.

5. Overcomplicating the data flow

Keep state in a single component for simple apps. Use stores and events only when multiple separate components need to communicate.

Practice Questions

  1. Why use a function for the component data instead of an object? Functions create a fresh instance each time, preventing shared mutable state between multiple components.

  2. What does Alpine.$persist() do? It wraps a value so it's automatically saved to localStorage and restored on page load.

  3. Why clone the task object in editTask? To avoid mutating the original task while editing. If the user cancels, the original is unchanged.

  4. What is the purpose of x-trap.noscroll? It traps keyboard focus inside the modal and prevents background scrolling, improving Accessibility and UX.

  5. How does the filteredTasks getter work? It's a computed property that returns a new array filtered by status, search query, and sorted by the selected criteria.

Challenge

Extend the project with categories management (add/edit/delete categories), due dates, and a calendar view. Use a separate Alpine component for the category manager with cross-component communication via $store.

FAQ

How do I organize Alpine code for a large project?

Use Alpine.data() to register reusable components. Use Alpine.store() for global state. Split functionality into separate files loaded via script tags.

Can this project work without a backend?

Yes. The task manager is fully client-side with localStorage persistence. To add a backend, replace the persistence layer with API calls.

How do I add authentication to this app?

Create a $store.auth that holds user state. Use x-init to check for existing sessions. Gate features behind conditional rendering with x-show.

Is Alpine suitable for large production apps?

Alpine excels at adding interactivity to server-rendered pages. For full SPAs with complex state, consider pairing Alpine with htmx or using a framework like Vue.

How do I test Alpine components?

Use Alpine's programmatic API: Alpine.data() to create instances, then call methods and assert on state. Cypress and Playwright work well for end-to-end testing.


What's Next

Congratulations on building a complete Alpine application! Continue with related topics:

Tutorial What You'll Learn
Transitions and Plugins Advanced animation and plugin patterns
JavaScript Array Methods The JavaScript fundamentals behind Alpine's reactivity

Related topics: localStorage and client-side persistence, CRUD application patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro