DEV Community

Cover image for How to Build a Task Management App Using Next.js 16 and Prisma 7
Yogesh Chavan
Yogesh Chavan

Posted on

How to Build a Task Management App Using Next.js 16 and Prisma 7

In this tutorial, you will build a Task Management App using Next.js 16 and Prisma 7 from scratch.

By creating this app, you will learn:

  1. How to set up Prisma 7 with the new Rust-free architecture

  2. How to configure a database with Prisma's driver adapter

  3. How to perform CRUD operations using Server Actions

  4. How to use the Next.js App Router for building full-stack applications

  5. How to create and run database migrations

  6. How to structure a Next.js project with Prisma ORM

and much more.

Prisma 7 introduces a major architectural shift – it's now "Rust-free" and requires the use of driver adapters for all database connections. This makes Prisma lighter, faster, and more compatible with modern JavaScript runtimes.

Note: This tutorial uses Prisma 7, which has breaking changes from earlier versions. If you're upgrading from Prisma 6, be aware that the setup process is different.

Prerequisites

Before you begin, make sure you have:

  • Node.js 20.9 or higher installed

  • Basic knowledge of React and Next.js

  • Familiarity with TypeScript (we'll use TypeScript throughout)

If you're new to Next.js and want to learn it from scratch from basics to advanced? Check out this course.

If you're new to TypeScript + React, check out my this article.

Initial Setup

Create a new Next.js project using create-next-app:

npx create-next-app@16.0.10 prisma-nextjs-task-management-app
Enter fullscreen mode Exit fullscreen mode

Here, we're specifying the exact and latest version of Next.js (at the time of writing this tutorial), which is 16.0.10. Therefore, the code in this tutorial will always work, even if a newer version with breaking changes is released in the future.

So you will not have any issues following this tutorial.

You can also use:

npx create-next-app@latest prisma-nextjs-task-management-app
Enter fullscreen mode Exit fullscreen mode

to always use the latest available Next.js version. However, for this tutorial, we will not use this command.

When prompted, type y to say yes to proceed with installation and press the enter key to go with recommended defaults, as can be seen below:

By going with the recommended default settings, we're going with these default settings:

✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? Yes
✔ Would you like to use Tailwind CSS? Yes
✔ Would you like your code inside a `src/` directory? No
✔ Would you like to use App Router? Yes
✔ Would you like to use Turbopack for `next dev`? Yes
✔ Would you like to customize the import alias? No
Enter fullscreen mode Exit fullscreen mode

Now, navigate into the project directory:

cd prisma-nextjs-task-management-app
Enter fullscreen mode Exit fullscreen mode

Now install the necessary dependencies for Prisma 7:

npm install prisma @types/better-sqlite3 --save-dev
npm install @prisma/client @prisma/adapter-better-sqlite3 dotenv
Enter fullscreen mode Exit fullscreen mode

Let's understand what each package does:

  • prisma – The Prisma CLI for migrations, schema management, and client generation

  • @prisma/client – The Prisma Client library for querying your database

  • @prisma/adapter-better-sqlite3 – The driver adapter that connects Prisma Client to SQLite (required in Prisma 7)

  • @types/better-sqlite3 – TypeScript type definitions for better-sqlite3

  • dotenv – Loads environment variables from your .env file

For this tutorial, we're using a SQLite database, so it's easier to understand the basics of Prisma and Next.js.

Note that the code you write to interact with the database and used in this tutorial will not change even if you use either SQLite/PostgreSQL/MySQL or any other database. That's the power of using an ORM like Prisma ORM.

However, if you want to learn how to work with a hosted PostgreSQL database, check out my course where we built a complete Event Management App From Scratch.

How to Initialize Prisma

Run the following command to initialize Prisma with SQLite:

npx prisma init --datasource-provider sqlite --output ../generated/prisma
Enter fullscreen mode Exit fullscreen mode

This command creates:

  1. A prisma folder with a schema.prisma file

  2. A .env file with a DATABASE_URL environment variable

  3. A prisma.config.ts file for Prisma configuration

How to Configure the Prisma Config File

Prisma 7 introduces a new configuration file called prisma.config.ts. This file separates your project configuration from your schema definition. Update or create the prisma.config.ts file in your project root with the following content:

import 'dotenv/config'
import { defineConfig } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: {
    url: process.env.DATABASE_URL || 'file:./prisma/dev.db',
  },
})
Enter fullscreen mode Exit fullscreen mode

Here, we're importing dotenv/config to load environment variables and defining the schema location and database URL.

Note that we're giving the path of ./prisma/ in the above file, because the dev.db file needs to be created inside the prisma folder, so everything will work as expected.

How to Set Up the Database Schema

Open the prisma/schema.prisma file and replace its contents with the following:

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Task {
  id          Int      @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean  @default(false)
  priority    String   @default("medium")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Let's understand what we've defined here:

Generator block:

  • provider = "prisma-client" – Uses the new Prisma 7 client generator (not prisma-client-js used in version 6 of prisma)

  • output = "../generated/prisma" – Specifies where the generated client files will be placed

Datasource block:

  • provider = "sqlite" – Specifies SQLite as our database

  • Note: In Prisma 7, the url is no longer in the schema file – it's in prisma.config.ts

Task model:

  • id – Auto-incrementing primary key

  • title – Required string for the task title

  • description – Optional string for task description

  • completed – Boolean flag with default value false

  • priority – String field with default value "medium"

  • createdAt – Timestamp set automatically when a task is created

  • updatedAt – Timestamp updated automatically when a task is modified

How to Configure the Path Alias

Make sure your tsconfig.json has the proper path alias for imports. It should include:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This is usually set up by default with create-next-app.

How to Run Your First Migration

Now let's create the database tables based on our schema. Run:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

This command:

  1. Creates the SQLite database file (dev.db) in the prisma folder

  2. Creates a new migration in prisma/migrations/

  3. Applies the migration to your database

  4. Generates the Prisma Client

You should see output similar to:

Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

Applying migration `20241215_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20241215_init/
    └─ migration.sql
Enter fullscreen mode Exit fullscreen mode

Now generate the Prisma Client:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Prisma Client is needed because that's the one that will interact with your database which can be SQLite/PostgreSQL/MySQL.

How to Create the Prisma Client Instance

In Prisma 7, you need to use a driver adapter to connect to your database. Create a new file called lib/prisma.ts:

mkdir lib
Enter fullscreen mode Exit fullscreen mode

Then create lib/prisma.ts with the following content:

import 'dotenv/config'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from '../generated/prisma/client'

const connectionString = process.env.DATABASE_URL || 'file:./prisma/dev.db'

const adapter = new PrismaBetterSqlite3({
  url: connectionString,
})

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    adapter,
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}
Enter fullscreen mode Exit fullscreen mode

Let's break down what this code does:

  1. Import the adapter: We import PrismaBetterSqlite3 from @prisma/adapter-better-sqlite3. This is required in Prisma 7 for all database connections.

  2. Create the adapter instance: We instantiate the adapter with our database URL.

  3. Singleton pattern: We use the global object to store a single Prisma Client instance. This prevents creating multiple connections during development when hot reloading occurs.

  4. Pass the adapter: The adapter is passed to PrismaClient in the constructor.

How to Create Server Actions for CRUD Operations

Next.js Server Actions allow us to write server-side code that can be called directly from our components. Create a new file called app/actions.ts:

'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export type TaskFormData = {
  title: string
  description?: string
  priority: string
}

// Create a new task
export async function createTask(formData: FormData) {
  const title = formData.get('title') as string
  const description = formData.get('description') as string
  const priority = formData.get('priority') as string

  if (!title || title.trim() === '') {
    return { error: 'Title is required' }
  }

  try {
    await prisma.task.create({
      data: {
        title: title.trim(),
        description: description?.trim() || null,
        priority: priority || 'medium',
      },
    })

    revalidatePath('/')
    return { success: true }
  } catch (error) {
    console.error('Failed to create task:', error)
    return { error: 'Failed to create task' }
  }
}

// Get all tasks
export async function getTasks() {
  try {
    const tasks = await prisma.task.findMany({
      orderBy: {
        createdAt: 'desc',
      },
    })
    return tasks
  } catch (error) {
    console.error('Failed to fetch tasks:', error)
    return []
  }
}

// Toggle task completion status
export async function toggleTaskComplete(id: number) {
  try {
    const task = await prisma.task.findUnique({
      where: { id },
    })

    if (!task) {
      return { error: 'Task not found' }
    }

    await prisma.task.update({
      where: { id },
      data: {
        completed: !task.completed,
      },
    })

    revalidatePath('/')
    return { success: true }
  } catch (error) {
    console.error('Failed to toggle task:', error)
    return { error: 'Failed to toggle task' }
  }
}

// Update a task
export async function updateTask(id: number, formData: FormData) {
  const title = formData.get('title') as string
  const description = formData.get('description') as string
  const priority = formData.get('priority') as string

  if (!title || title.trim() === '') {
    return { error: 'Title is required' }
  }

  try {
    await prisma.task.update({
      where: { id },
      data: {
        title: title.trim(),
        description: description?.trim() || null,
        priority: priority || 'medium',
      },
    })

    revalidatePath('/')
    return { success: true }
  } catch (error) {
    console.error('Failed to update task:', error)
    return { error: 'Failed to update task' }
  }
}

// Delete a task
export async function deleteTask(id: number) {
  try {
    await prisma.task.delete({
      where: { id },
    })

    revalidatePath('/')
    return { success: true }
  } catch (error) {
    console.error('Failed to delete task:', error)
    return { error: 'Failed to delete task' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's understand what each function does:

createTask: Uses prisma.task.create() to insert a new task into the database. We extract form data, validate the title, and return appropriate responses.

getTasks: Uses prisma.task.findMany() to retrieve all tasks, ordered by creation date in descending order.

toggleTaskComplete: First finds the task using prisma.task.findUnique(), then uses prisma.task.update() to toggle the completed field.

updateTask: Uses prisma.task.update() to modify an existing task's title, description, and priority.

deleteTask: Uses prisma.task.delete() to remove a task from the database.

Note that we call revalidatePath('/') after each mutation to automatically refresh the page data, so we don't need to reload the page to see the added/updated/deleted data.

How to Create the Task Components

Now let's create the UI components. First, create a components folder:

mkdir components
Enter fullscreen mode Exit fullscreen mode

TaskForm Component

Create components/TaskForm.tsx:

'use client'

import { useState } from 'react'
import { createTask, updateTask } from '@/app/actions'

type TaskFormProps = {
  task?: {
    id: number
    title: string
    description: string | null
    priority: string
  }
  onSuccess?: () => void
}

export default function TaskForm({ task, onSuccess }: TaskFormProps) {
  const [error, setError] = useState<string | null>(null)
  const [isSubmitting, setIsSubmitting] = useState(false)

  const isEditing = !!task

  async function handleSubmit(formData: FormData) {
    setError(null)
    setIsSubmitting(true)

    try {
      const result = isEditing
        ? await updateTask(task.id, formData)
        : await createTask(formData)

      if (result.error) {
        setError(result.error)
      } else if (onSuccess) {
        onSuccess()
      }
    } catch (err) {
      setError('Something went wrong')
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form action={handleSubmit} className="space-y-4">
      {error && (
        <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
          {error}
        </div>
      )}

      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
          Title *
        </label>
        <input
          type="text"
          id="title"
          name="title"
          defaultValue={task?.title || ''}
          required
          className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="Enter task title"
        />
      </div>

      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
          Description
        </label>
        <textarea
          id="description"
          name="description"
          defaultValue={task?.description || ''}
          rows={3}
          className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="Enter task description (optional)"
        />
      </div>

      <div>
        <label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
          Priority
        </label>
        <select
          id="priority"
          name="priority"
          defaultValue={task?.priority || 'medium'}
          className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        >
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {isSubmitting ? 'Saving...' : isEditing ? 'Update Task' : 'Add Task'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

This component handles both creating new tasks and editing existing ones. If a task prop is passed, it pre-fills the form and uses the updateTask action. Otherwise, it uses the createTask action.

TaskItem Component

Create components/TaskItem.tsx:

"use client";

import { deleteTask, toggleTaskComplete } from "@/app/actions";
import { useState } from "react";
import TaskForm from "./TaskForm";

type Task = {
  id: number;
  title: string;
  description: string | null;
  completed: boolean;
  priority: string;
  createdAt: Date;
  updatedAt: Date;
};

type TaskItemProps = {
  task: Task;
};

const priorityColors = {
  low: "bg-green-100 text-green-800",
  medium: "bg-yellow-100 text-yellow-800",
  high: "bg-red-100 text-red-800",
};

export default function TaskItem({ task }: TaskItemProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);

  async function handleToggle() {
    await toggleTaskComplete(task.id);
  }

  async function handleDelete() {
    if (confirm("Are you sure you want to delete this task?")) {
      setIsDeleting(true);
      await deleteTask(task.id);
    }
  }

  if (isEditing) {
    return (
      <div className="bg-white p-4 rounded-lg shadow-md border border-gray-200">
        <div className="flex justify-between items-center mb-4">
          <h3 className="text-lg font-medium">Edit Task</h3>
          <button
            onClick={() => setIsEditing(false)}
            className="text-gray-500 hover:text-gray-700"
          >
            Cancel
          </button>
        </div>
        <TaskForm task={task} onSuccess={() => setIsEditing(false)} />
      </div>
    );
  }

  return (
    <div
      className={`bg-white p-4 rounded-lg shadow-md border border-gray-200 transition-opacity ${
        task.completed ? "opacity-60" : ""
      }`}
    >
      <div className="flex items-start gap-3">
        <input
          type="checkbox"
          checked={task.completed}
          onChange={handleToggle}
          className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
        />

        <div className="flex-1">
          <div className="flex items-center gap-2 mb-1">
            <h3
              className={`font-medium ${
                task.completed ? "line-through text-gray-500" : "text-gray-900"
              }`}
            >
              {task.title}
            </h3>
            <span
              className={`px-2 py-0.5 text-xs font-medium rounded-full ${
                priorityColors[task.priority as keyof typeof priorityColors]
              }`}
            >
              {task.priority}
            </span>
          </div>

          {task.description && (
            <p className="text-gray-600 text-sm mb-2">{task.description}</p>
          )}

          <p className="text-xs text-gray-400">
            Created: {new Date(task.createdAt).toISOString().split("T")[0]}
          </p>
        </div>

        <div className="flex gap-2">
          <button
            onClick={() => setIsEditing(true)}
            className="text-blue-600 hover:text-blue-800 text-sm font-medium"
          >
            Edit
          </button>
          <button
            onClick={handleDelete}
            disabled={isDeleting}
            className="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50"
          >
            {isDeleting ? "Deleting..." : "Delete"}
          </button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component displays a single task with:

  • A checkbox to toggle completion status

  • The task title with strikethrough styling when completed

  • A priority badge with color coding

  • Optional description

  • Creation date

  • Edit and Delete buttons

When editing, it renders the TaskForm component with the current task data.

TaskList Component

Create components/TaskList.tsx:

import { getTasks } from '@/app/actions'
import TaskItem from './TaskItem'

export default async function TaskList() {
  const tasks = await getTasks()

  if (tasks.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No tasks yet. Add your first task above!</p>
      </div>
    )
  }

  const completedTasks = tasks.filter((task) => task.completed)
  const pendingTasks = tasks.filter((task) => !task.completed)

  return (
    <div className="space-y-6">
      {pendingTasks.length > 0 && (
        <div>
          <h2 className="text-lg font-semibold text-gray-700 mb-3">
            Pending Tasks ({pendingTasks.length})
          </h2>
          <div className="space-y-3">
            {pendingTasks.map((task) => (
              <TaskItem key={task.id} task={task} />
            ))}
          </div>
        </div>
      )}

      {completedTasks.length > 0 && (
        <div>
          <h2 className="text-lg font-semibold text-gray-700 mb-3">
            Completed Tasks ({completedTasks.length})
          </h2>
          <div className="space-y-3">
            {completedTasks.map((task) => (
              <TaskItem key={task.id} task={task} />
            ))}
          </div>
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is a Server Component that fetches all tasks and renders them. It separates tasks into pending and completed sections for better organization.

AddTaskButton Component

Create components/AddTaskButton.tsx:

'use client'

import { useState } from 'react'
import TaskForm from './TaskForm'

export default function AddTaskButton() {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      {!isOpen ? (
        <button
          onClick={() => setIsOpen(true)}
          className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors font-medium"
        >
          + Add New Task
        </button>
      ) : (
        <div className="bg-white p-6 rounded-lg shadow-md border border-gray-200">
          <div className="flex justify-between items-center mb-4">
            <h2 className="text-xl font-semibold text-gray-800">Add New Task</h2>
            <button
              onClick={() => setIsOpen(false)}
              className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
            >
              ×
            </button>
          </div>
          <TaskForm onSuccess={() => setIsOpen(false)} />
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This component toggles between showing an "Add New Task" button and the task form.

How to Create the Main Page

Now let's update the main page to display our task management interface. Replace the contents of app/page.tsx:

import { Suspense } from 'react'
import AddTaskButton from '@/components/AddTaskButton'
import TaskList from '@/components/TaskList'

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <header className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-800 mb-2">Task Management</h1>
          <p className="text-gray-600">Organize your tasks efficiently with Prisma & Next.js</p>
        </header>

        <div className="space-y-6">
          <AddTaskButton />

          <Suspense
            fallback={
              <div className="text-center py-12">
                <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
                <p className="text-gray-500 mt-2">Loading tasks...</p>
              </div>
            }
          >
            <TaskList />
          </Suspense>
        </div>
      </div>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

We wrap TaskList in a Suspense boundary to show a loading state while the tasks are being fetched.

How to Run the Application

Now you're ready to run the application. Start the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open your browser and navigate to http://localhost:3000. You should see the task management application screen as shown below:

Try the following:

  1. Click "Add New Task" to open the form

  2. Enter a title like "Learn Prisma 7"

  3. Add a description (optional)

  4. Select a priority level

  5. Click "Add Task"

Your task should appear in the list!

Now you can:

  • Click "Edit" to modify a task

  • Click the checkbox to mark tasks as complete

  • Click "Delete" to delete a task

How to Explore Your Data with Prisma Studio

Prisma comes with a built-in GUI for exploring and editing your database. Run:

npx prisma studio
Enter fullscreen mode Exit fullscreen mode

This opens Prisma Studio in your browser at http://localhost:51212. You can:

  • View all your tasks in a table format

  • Add new records directly

  • Edit existing records

  • Delete records

  • Filter and sort data

Make sure to add some tasks from the application to see them in the studio as we deleted first task above.

Understanding Prisma 7's New Architecture

Prisma 7 introduces several important changes that you should understand:

Driver Adapters

In previous versions, Prisma used Rust-based engines for database connections. Prisma 7 moves to a "Rust-free" architecture that uses JavaScript driver adapters instead.

For SQLite, we use @prisma/adapter-better-sqlite3:

import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'

const adapter = new PrismaBetterSqlite3({
  url: 'file:./prisma/dev.db',
})

const prisma = new PrismaClient({ adapter })
Enter fullscreen mode Exit fullscreen mode

This approach offers several benefits:

  • Smaller package size (no Rust binaries)

  • Better compatibility with edge runtimes

  • Faster cold starts

  • More flexible database driver options

New Generator Provider

The generator provider has changed from prisma-client-js to prisma-client:

generator client {
  provider = "prisma-client"  // New in Prisma 7
  output   = "../generated/prisma"
}
Enter fullscreen mode Exit fullscreen mode

Configuration File

The new prisma.config.ts file centralizes configuration that was previously spread across multiple files:

import { defineConfig } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  datasource: {
    url: process.env.DATABASE_URL,
  },
})
Enter fullscreen mode Exit fullscreen mode

Adding More Features

Let's extend our application with a few more useful features.

Task Filtering

Add a filter component to show tasks by priority. Create components/TaskFilter.tsx:

'use client'

import { useRouter, useSearchParams } from 'next/navigation'

export default function TaskFilter() {
  const router = useRouter()
  const searchParams = useSearchParams()
  const currentFilter = searchParams.get('priority') || 'all'

  function handleFilterChange(priority: string) {
    const params = new URLSearchParams(searchParams)
    if (priority === 'all') {
      params.delete('priority')
    } else {
      params.set('priority', priority)
    }
    router.push(`/?${params.toString()}`)
  }

  const filters = ['all', 'high', 'medium', 'low']

  return (
    <div className="flex gap-2 justify-center">
      {filters.map((filter) => (
        <button
          key={filter}
          onClick={() => handleFilterChange(filter)}
          className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
            currentFilter === filter
              ? 'bg-blue-600 text-white'
              : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
          }`}
        >
          {filter.charAt(0).toUpperCase() + filter.slice(1)}
        </button>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Update the getTasks function in app/actions.ts to support filtering:

export async function getTasks(priority?: string) {
  try {
    const where = priority && priority !== 'all' ? { priority } : {}

    const tasks = await prisma.task.findMany({
      where,
      orderBy: {
        createdAt: 'desc',
      },
    })
    return tasks
  } catch (error) {
    console.error('Failed to fetch tasks:', error)
    return []
  }
}
Enter fullscreen mode Exit fullscreen mode

Task Statistics

Create a component to display task statistics. Create components/TaskStats.tsx:

import { prisma } from '@/lib/prisma'

export default async function TaskStats() {
  const [total, completed, highPriority] = await Promise.all([
    prisma.task.count(),
    prisma.task.count({ where: { completed: true } }),
    prisma.task.count({ where: { priority: 'high', completed: false } }),
  ])

  const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0

  return (
    <div className="grid grid-cols-3 gap-4 mb-6">
      <div className="bg-white p-4 rounded-lg shadow-sm text-center">
        <p className="text-2xl font-bold text-gray-800">{total}</p>
        <p className="text-sm text-gray-500">Total Tasks</p>
      </div>
      <div className="bg-white p-4 rounded-lg shadow-sm text-center">
        <p className="text-2xl font-bold text-green-600">{completionRate}%</p>
        <p className="text-sm text-gray-500">Completed</p>
      </div>
      <div className="bg-white p-4 rounded-lg shadow-sm text-center">
        <p className="text-2xl font-bold text-red-600">{highPriority}</p>
        <p className="text-sm text-gray-500">High Priority</p>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, use this component in the app/page.tsx file after the header tag for a quick overview of your tasks:

import AddTaskButton from "@/components/AddTaskButton";
import TaskList from "@/components/TaskList";
import TaskStats from "@/components/TaskStats";
import { Suspense } from "react";

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-100 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <header className="text-center mb-8">
          <h1 className="text-3xl font-bold text-gray-800 mb-2">
            Task Management
          </h1>
          <p className="text-gray-600">
            Organize your tasks efficiently with Prisma & Next.js
          </p>
        </header>

        <TaskStats /> {/* show list of tasks */}

        <div className="space-y-6">
          <AddTaskButton />

          <Suspense
            fallback={
              <div className="text-center py-12">
                <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
                <p className="text-gray-500 mt-2">Loading tasks...</p>
              </div>
            }
          >
            <TaskList />
          </Suspense>
        </div>
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you add a couple of tasks with high priority and make some of them as completed you will see screen with stats as shown below:

Database Migrations in Production

When you need to change your database schema, Prisma Migrate helps you manage those changes safely.

Adding a New Field

Let's say you want to add a dueDate field to tasks. First, update your schema from prisma/schema.prisma file:

model Task {
  id          Int       @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean   @default(false)
  priority    String    @default("medium")
  dueDate     DateTime?  // New field
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Then create and apply the migration by executing the following command from terminal:

npx prisma migrate dev --name add-due-date
Enter fullscreen mode Exit fullscreen mode

This creates a new migration file and updates your database.

Now, If you navigate to http://localhost:51212, you will see the new dueDate column added in the prisma studio.

If you’re not able to see it, make sure to execute the npx prisma studio command again in separate terminal and then try it out.

Viewing Migration History

Check your migration history:

npx prisma migrate status
Enter fullscreen mode Exit fullscreen mode

Resetting the Database

During development, you might want to reset your database:

npx prisma migrate reset
Enter fullscreen mode Exit fullscreen mode

Warning: This deletes all data in your database. Only use this in development!

Best Practices for Production

Here are some best practices when using Prisma with Next.js in production:

1. Use Environment Variables

Never hardcode database URLs. Always use environment variables in .env file:

DATABASE_URL="file:./prisma/prod.db"
Enter fullscreen mode Exit fullscreen mode

2. Add a Postinstall Script

Add this to your package.json to ensure Prisma Client is generated after installing dependencies:

{
  "scripts": {
    "postinstall": "prisma generate"
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Handle Connection Errors Gracefully

Wrap your database operations in try-catch blocks and provide meaningful error messages to users.

4. Use Transactions for Related Operations

When you need to perform multiple related database operations, use transactions:

await prisma.$transaction([
  prisma.task.update({ where: { id: 1 }, data: { completed: true } }),
  prisma.task.update({ where: { id: 2 }, data: { completed: true } }),
])
Enter fullscreen mode Exit fullscreen mode

Conclusion

Congratulations! You've built a complete Task Management application using Next.js 15 and Prisma 7 with SQLite.

You can find the complete source code for this application in this repository.

As we cannot cover everything in this tutorial, If you want to learn Next.js from scratch and learn in detail about prisma step-by-step with table relationships, login authentication, multiple routes, server actions and more all while building a large application then, do check out the Mastering Next.js course.


In this tutorial you learned how to:

  • Set up Prisma 7 with the new Rust-free architecture

  • Configure the new prisma.config.ts file

  • Use driver adapters for database connections

  • Create and run database migrations

  • Build Server Actions for CRUD operations

  • Create a responsive UI with React and Tailwind CSS

  • Use Prisma Studio for database exploration

Prisma 7's new architecture makes it lighter and more compatible with modern JavaScript runtimes. The driver adapter pattern gives you more flexibility in how you connect to your database.

Next Steps

Here are some ways you can extend this application:

  • Add user authentication with NextAuth.js

  • Implement task categories or tags

  • Add drag-and-drop for task reordering

  • Set up due date reminders

  • Deploy to Vercel or another hosting platform

  • Migrate to PostgreSQL for a production-ready database

Resources


Thanks for reading!

Access The Ultimate React Ebooks Collection By Clicking The Image Below👇

Download The Complete Redux Toolkit Ebook Here

Download The useReducer Hook Ebook Here

React Ebooks Collection

Top comments (0)