DEV Community

Abdur Rakib Rony
Abdur Rakib Rony

Posted on

2

Building a Full-Stack User Management System with Next.js 14, GraphQL, Prisma, and PostgreSQL

In this comprehensive guide, we'll build a complete user management system using modern web technologies. You can find the complete source code in my GitHub repository.
Prerequisites

  • Node.js installed on your machine
  • PostgreSQL database
  • Basic knowledge of React and Next.js
  • Understanding of GraphQL concepts

Project Setup
First, create a new Next.js project and install the required dependencies:

npx create-next-app@latest graphql-user-management
cd graphql-user-management

npm install @apollo/server @as-integrations/next @prisma/client graphql-tag lucide-react
npm install -D prisma tailwindcss postcss
Enter fullscreen mode Exit fullscreen mode

Project Structure

├── app/
│   ├── actions/
│   │   └── userActions.js
│   ├── api/
│   │   └── graphql/
│   │       └── route.js
│   ├── components/
│   │   └── UserManagement.jsx
│   ├── graphql/
│   │   └── schema.js
│   ├── lib/
│   │   └── prisma.js
│   ├── page.js
│   └── layout.js
├── prisma/
│   └── schema.prisma
└── package.json
Enter fullscreen mode Exit fullscreen mode

Database Setup with Prisma
First, let's set up our database schema using Prisma. Create a new file prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

GraphQL Schema
Create app/graphql/schema.jsto define our GraphQL types and operations:

import { gql } from "graphql-tag";

export const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    createdAt: String!
    updatedAt: String!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
    updateUser(id: ID!, name: String, email: String): User!
    deleteUser(id: ID!): Boolean!
  }
`;
Enter fullscreen mode Exit fullscreen mode

Prisma Client Setup
Create app/lib/prisma.js to initialize the Prisma client:

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default prisma
Enter fullscreen mode Exit fullscreen mode

GraphQL API Route
Create app/api/graphql/route.js to set up Apollo Server:

import { ApolloServer } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { typeDefs } from "@/graphql/schema";
import prisma from "@/lib/prisma";

const resolvers = {
  Query: {
    users: async () => {
      const users = await prisma.user.findMany({
        orderBy: {
          createdAt: "desc",
        },
      });
      return users;
    },
    user: async (_, { id }) => {
      const user = await prisma.user.findUnique({
        where: {
          id: parseInt(id),
        },
      });
      return user;
    },
  },
  Mutation: {
    createUser: async (_, { name, email }) => {
      try {
        const user = await prisma.user.create({
          data: {
            name,
            email,
          },
        });
        return user;
      } catch (error) {
        if (error.code === "P2002") {
          throw new Error("A user with this email already exists");
        }
        throw error;
      }
    },
    updateUser: async (_, { id, name, email }) => {
      try {
        const user = await prisma.user.update({
          where: {
            id: parseInt(id),
          },
          data: {
            name,
            email,
          },
        });
        return user;
      } catch (error) {
        if (error.code === "P2002") {
          throw new Error("A user with this email already exists");
        }
        throw error;
      }
    },
    deleteUser: async (_, { id }) => {
      try {
        await prisma.user.delete({
          where: {
            id: parseInt(id),
          },
        });
        return true;
      } catch (error) {
        return false;
      }
    },
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const handler = startServerAndCreateNextHandler(server);
export { handler as GET, handler as POST };
Enter fullscreen mode Exit fullscreen mode

Server Actions
Create app/actions/userActions.js to handle server-side mutations:

"use server";

import { revalidatePath } from "next/cache";

async function fetchGraphQL(query, variables = {}) {
  try {
    const response = await fetch("http://localhost:3000/api/graphql", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ query, variables }),
      cache: "no-store",
      next: { 
        tags: ["users"],
        revalidate: 0 
      }
    });

    const result = await response.json();

    if (result.errors) {
      throw new Error(result.errors[0].message);
    }

    return result;
  } catch (error) {
    throw new Error(error.message || "An error occurred");
  }
}

export async function getUsers() {
  try {
    const { data } = await fetchGraphQL(`
      query GetUsers {
        users {
          id
          name
          email
          createdAt
          updatedAt
        }
      }
    `);
    return { users: data.users };
  } catch (error) {
    return { error: error.message };
  }
}

export async function createUser(name, email) {
  try {
    const { data } = await fetchGraphQL(
      `
      mutation CreateUser($name: String!, $email: String!) {
        createUser(name: $name, email: $email) {
          id
          name
          email
        }
      }
    `,
      { name, email }
    );

    revalidatePath("/");
    return { success: true, user: data.createUser };
  } catch (error) {
    return { error: error.message };
  }
}

export async function updateUser(id, name, email) {
  try {
    const { data } = await fetchGraphQL(
      `
      mutation UpdateUser($id: ID!, $name: String!, $email: String!) {
        updateUser(id: $id, name: $name, email: $email) {
          id
          name
          email
        }
      }
    `,
      { id, name, email }
    );

    revalidatePath("/");
    return { success: true, user: data.updateUser };
  } catch (error) {
    return { error: error.message };
  }
}

export async function deleteUser(id) {
  try {
    await fetchGraphQL(
      `
      mutation DeleteUser($id: ID!) {
        deleteUser(id: $id)
      }
    `,
      { id }
    );

    revalidatePath("/");
    return { success: true };
  } catch (error) {
    return { error: error.message };
  }
}
Enter fullscreen mode Exit fullscreen mode

User Management Component
Create app/components/UserManagement.jsx:

"use client";

import React, { useState, useTransition } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { createUser, updateUser, deleteUser } from '@/app/actions/userActions';

function UserManagement({ users = [] }) {
  const [isPending, startTransition] = useTransition();
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [editingUser, setEditingUser] = useState(null);
  const [error, setError] = useState(null);

  const handleCreateUser = async (e) => {
    e.preventDefault();
    setError(null);

    startTransition(async () => {
      try {
        const result = await createUser(name, email);
        if (result.error) {
          setError(result.error);
        } else {
          setName("");
          setEmail("");
        }
      } catch (err) {
        setError(err.message);
      }
    });
  };

  const handleUpdateUser = async (e) => {
    e.preventDefault();
    setError(null);

    startTransition(async () => {
      try {
        const result = await updateUser(editingUser.id, name, email);
        if (result.error) {
          setError(result.error);
        } else {
          setEditingUser(null);
          setName("");
          setEmail("");
        }
      } catch (err) {
        setError(err.message);
      }
    });
  };

  const handleDeleteUser = async (id) => {
    setError(null);

    startTransition(async () => {
      try {
        const result = await deleteUser(id);
        if (result.error) {
          setError(result.error);
        }
      } catch (err) {
        setError(err.message);
      }
    });
  };

  return (
    <div className="bg-white rounded-lg shadow-md w-full max-w-4xl mx-auto mt-8">
      <div className="p-6 border-b border-gray-200">
        <h2 className="text-xl font-semibold">User Management</h2>
      </div>

      <div className="p-6">
        {error && (
          <div className="mb-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-md">
            {error}
          </div>
        )}

        <form
          onSubmit={editingUser ? handleUpdateUser : handleCreateUser}
          className="space-y-4 mb-8"
        >
          <input
            type="text"
            placeholder="Name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
            disabled={isPending}
            required
          />
          <input
            type="email"
            placeholder="Email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
            disabled={isPending}
            required
          />
          <button
            type="submit"
            className={`w-full py-2 px-4 rounded-md text-white font-medium 
              ${isPending ? "bg-blue-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600"}`}
            disabled={isPending}
          >
            {isPending ? (
              <svg className="animate-spin h-5 w-5 mx-auto" viewBox="0 0 24 24">
                <circle
                  className="opacity-25"
                  cx="12"
                  cy="12"
                  r="10"
                  stroke="currentColor"
                  strokeWidth="4"
                  fill="none"
                />
                <path
                  className="opacity-75"
                  fill="currentColor"
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                />
              </svg>
            ) : editingUser ? (
              "Update User"
            ) : (
              "Create User"
            )}
          </button>

          {editingUser && (
            <button
              type="button"
              onClick={() => {
                setEditingUser(null);
                setName("");
                setEmail("");
              }}
              className="w-full py-2 px-4 border border-gray-300 rounded-md text-gray-700 font-medium hover:bg-gray-50 disabled:bg-gray-100"
              disabled={isPending}
            >
              Cancel Edit
            </button>
          )}
        </form>

        {isPending && (
          <div className="fixed top-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-md shadow-lg">
            Saving changes...
          </div>
        )}

        <div className="space-y-4">
          {users.map((user) => (
            <div
              key={user.id}
              className="flex items-center justify-between p-4 border border-gray-200 rounded-md"
            >
              <div>
                <h3 className="font-medium">{user.name}</h3>
                <p className="text-sm text-gray-500">{user.email}</p>
              </div>
              <div className="flex space-x-2">
                <button
                  onClick={() => {
                    setEditingUser(user);
                    setName(user.name);
                    setEmail(user.email);
                  }}
                  disabled={isPending}
                  className="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded-md disabled:opacity-50"
                >
                  <Pencil className="h-4 w-4" />
                </button>
                <button
                  onClick={() => handleDeleteUser(user.id)}
                  disabled={isPending}
                  className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md disabled:opacity-50"
                >
                  <Trash2 className="h-4 w-4" />
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

export default UserManagement;
Enter fullscreen mode Exit fullscreen mode

Root Page Component
Create app/page.js:

import UserManagement from "./components/UserManagement";
import { getUsers } from "./actions/userActions";

export const dynamic = "force-dynamic";
export const revalidate = 0;

export default async function HomePage() {
  const { users, error } = await getUsers();

  if (error) {
    return <div>Error loading users: {error}</div>;
  }

  return <UserManagement users={users} />;
}

Enter fullscreen mode Exit fullscreen mode

Setting Up Environment Variables
Create a .env file in your root directory:

DATABASE_URL="postgresql://username:password@localhost:5432/your_database_name"
Enter fullscreen mode Exit fullscreen mode

Replace the values with your actual PostgreSQL database credentials.

Package.json Configuration
Here's the complete package.json configuration:

{
  "name": "graphql",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@apollo/server": "^4.11.2",
    "@as-integrations/next": "^3.2.0",
    "@prisma/client": "^5.22.0",
    "graphql-tag": "^2.12.6",
    "lucide-react": "^0.456.0",
    "next": "15.0.3",
    "react": "19.0.0-rc-66855b96-20241106",
    "react-dom": "19.0.0-rc-66855b96-20241106"
  },
  "devDependencies": {
    "eslint": "^8",
    "eslint-config-next": "15.0.3",
    "postcss": "^8",
    "prisma": "^5.22.0",
    "tailwindcss": "^3.4.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Initialize Prisma

npx prisma generate
npx prisma db push
Enter fullscreen mode Exit fullscreen mode

Go to code

https://github.com/abdur-rakib-rony/nextjs-graphql-postgres-prisma-crud-operation
Enter fullscreen mode Exit fullscreen mode

Happy coding! 🚀

Top comments (0)

Heroku

This site is powered by Heroku

Heroku was created by developers, for developers. Get started today and find out why Heroku has been the platform of choice for brands like DEV for over a decade.

Sign Up

👋 Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay