Prisma + Next.js — fullstack w weekend (tutorial)

Prisma + Next.js 16 w praktyce: schema, migracje, CRUD, Server Actions, Auth.js i deployment bez skrótów, które później bolą w prawdziwej aplikacji.

Opublikowano

1 grudnia 2025 14:45

Czytanie

4 min czytania

Aktualizacja

15 kwietnia 2026 11:52

Chcesz zbudować fullstack aplikację, ale backend wydaje się skomplikowany? Next.js + Prisma to combo, które pozwala frontend developerowi zbudować pełną aplikację bez bólu głowy.

W tym tutorialu zbudujemy aplikację do zarządzania zadaniami (todo app) z:

  • Next.js 16 (App Router)
  • Prisma (ORM)
  • PostgreSQL
  • Autoryzacją
  • Deploymentem na Vercel

Krótka odpowiedź: Prisma to ORM z type-safe API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami., który świetnie współgra z Next.js — definiujesz schema, generujesz klienta i dostajesz w pełni typowane operacje na bazie danych. W połączeniu z Server Actions i Auth.js możesz zbudować kompletną fullstack aplikację z autentykacją i deploymentem na Vercel bez znajomości osobnego backendu. Tutorial prowadzi przez cały proces od zera do działającego CRUD to skrót od Create, Read, Update, Delete, czyli podstawowych operacji wykonywanych na danych. z autoryzacją.

Setup projektu

Utwórz projekt Next.js

Code
npx create-next-app@latest my-todo-app --typescript --tailwind
cd my-todo-app

Zainstaluj Prisma

Code
npm install prisma @prisma/client
npx prisma init

To utworzy:

  • prisma/schema.prisma — schemat bazy
  • .env — zmienne środowiskowe

Konfiguracja bazy danych

Użyjemy PostgreSQL. Możesz użyć:

  • Lokalnie: Docker lub instalacja
  • Cloud: Supabase, Railway, Neon (darmowe tier)
Code
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

Schema Prisma

Code
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String
  todos     Todo[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Todo {
  id        String   @id @default(cuid())
  title     String
  completed Boolean  @default(false)
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Migracja

Code
# Utwórz migrację
npx prisma migrate dev --name init
 
# Wygeneruj klienta
npx prisma generate
 
# Podgląd bazy (opcjonalnie)
npx prisma studio

Prisma Client

Code
// lib/prisma.ts
import { PrismaClient } from '@prisma/client'
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
 
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Ten pattern zapobiega tworzeniu wielu instancji klienta w development.

API Routes — CRUD

Lista zadań

Code
// app/api/todos/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
 
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const userId = searchParams.get('userId')
  
  if (!userId) {
    return NextResponse.json({ error: 'userId required' }, { status: 400 })
  }
  
  const todos = await prisma.todo.findMany({
    where: { userId },
    orderBy: { createdAt: 'desc' }
  })
  
  return NextResponse.json(todos)
}
 
export async function POST(request: Request) {
  const body = await request.json()
  const { title, userId } = body
  
  if (!title || !userId) {
    return NextResponse.json({ error: 'title and userId required' }, { status: 400 })
  }
  
  const todo = await prisma.todo.create({
    data: { title, userId }
  })
  
  return NextResponse.json(todo, { status: 201 })
}

Pojedyncze zadanie

Code
// app/api/todos/[id]/route.ts
import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'
 
export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()
 
  const todo = await prisma.todo.update({
    where: { id },
    data: body
  })
 
  return NextResponse.json(todo)
}
 
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
 
  await prisma.todo.delete({
    where: { id }
  })
 
  return new NextResponse(null, { status: 204 })
}

Server Actions (alternatywa)

Code
// app/actions/todos.ts
'use server'
 
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
 
export async function createTodo(formData: FormData) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) throw new Error('Unauthorized')
 
  const title = formData.get('title') as string
 
  await prisma.todo.create({
    data: { title, userId }
  })
  
  revalidatePath('/todos')
}
 
export async function toggleTodo(id: string) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) throw new Error('Unauthorized')
 
  const todo = await prisma.todo.findFirst({
    where: { id, userId },
  })
  if (!todo) throw new Error('Todo not found')
  
  await prisma.todo.update({
    where: { id },
    data: { completed: !todo.completed }
  })
  
  revalidatePath('/todos')
}
 
export async function deleteTodo(id: string) {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) throw new Error('Unauthorized')
 
  const todo = await prisma.todo.findFirst({
    where: { id, userId },
  })
  if (!todo) throw new Error('Todo not found')
 
  await prisma.todo.delete({ where: { id } })
  revalidatePath('/todos')
}

Frontend — komponenty

Lista zadań

Code
// app/todos/page.tsx
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { TodoList } from '@/components/TodoList'
import { AddTodoForm } from '@/components/AddTodoForm'
 
export default async function TodosPage() {
  const session = await auth()
  const userId = session?.user?.id
  if (!userId) redirect('/login')
 
  const todos = await prisma.todo.findMany({
    where: { userId },
    orderBy: { createdAt: 'desc' }
  })
 
  return (
    <div className="max-w-2xl mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Moje zadania</h1>
      <AddTodoForm />
      <TodoList todos={todos} />
    </div>
  )
}

Formularz dodawania

Code
// components/AddTodoForm.tsx
'use client'
 
import { createTodo } from '@/app/actions/todos'
import { useRef } from 'react'
 
export function AddTodoForm() {
  const formRef = useRef<HTMLFormElement>(null)
 
  async function handleSubmit(formData: FormData) {
    await createTodo(formData)
    formRef.current?.reset()
  }
 
  return (
    <form ref={formRef} action={handleSubmit} className="flex gap-2 mb-4">
      <input
        type="text"
        name="title"
        placeholder="Nowe zadanie..."
        className="flex-1 px-4 py-2 border rounded"
        required
      />
      <button
        type="submit"
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        Dodaj
      </button>
    </form>
  )
}

Element listy

Code
// components/TodoItem.tsx
'use client'
 
import { toggleTodo, deleteTodo } from '@/app/actions/todos'
import { Todo } from '@prisma/client'
 
export function TodoItem({ todo }: { todo: Todo }) {
  return (
    <div className="flex items-center gap-2 p-2 border-b">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => toggleTodo(todo.id)}
        className="w-5 h-5"
      />
      <span className={todo.completed ? 'line-through text-gray-400' : ''}>
        {todo.title}
      </span>
      <button
        onClick={() => deleteTodo(todo.id)}
        className="ml-auto text-red-500 hover:text-red-700"
      >
        Usuń
      </button>
    </div>
  )
}

W realnej aplikacji warto dołożyć useTransition(), stan pending i blokadę przycisków podczas mutacji. Tutorial ma pokazać fundament, ale nie warto zostawiać użytkownika z wrażeniem, że kliknięcia bez feedbacku to wystarczający UX, czyli User Experience, opisuje całe doświadczenie użytkownika podczas korzystania z produktu..

Autoryzacja z Auth.js (dawniej NextAuth.js)

Pakiet nadal instaluje się jako next-auth, ale aktualna dokumentacja i rozwój projektu są prowadzone pod marką Auth.js.

Code
npm install next-auth @auth/prisma-adapter bcryptjs
npm install -D @types/bcryptjs

Rozszerz schema

Code
// Dodaj do schema.prisma
model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)
 
  @@unique([provider, providerAccountId])
}
 
model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}
 
// Zaktualizuj User
model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  password      String?
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  todos         Todo[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

Konfiguracja Auth.js

Code
// auth.ts
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import bcrypt from 'bcryptjs'
 
import { prisma } from '@/lib/prisma'
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/login',
  },
  providers: [
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        })
 
        if (!user?.password) {
          return null
        }
 
        const passwordMatch = await bcrypt.compare(
          credentials.password as string,
          user.password
        )
 
        if (!passwordMatch) {
          return null
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user?.id) {
        token.id = user.id
      }
      return token
    },
    async session({ session, token }) {
      if (session.user && token.id) {
        session.user.id = token.id as string
      }
      return session
    },
  },
})
Code
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
 
export const { GET, POST } = handlers

Jeśli wybierasz logowanie OAuth to standard delegowanego dostępu, który pozwala logować użytkownika przez zewnętrznego dostawcę bez ujawniania hasła. przez Google czy GitHub, pole password w modelu User przestaje być potrzebne. Trzymaj je tylko wtedy, gdy naprawdę wdrażasz własny login credentials.

Deployment na Vercel

Przygotuj bazę danych

Użyj managed PostgreSQL:

  • Vercel Postgres (wbudowane)
  • Supabase (darmowy tier)
  • Railway (darmowy tier)

Zmienne środowiskowe

W Vercel Dashboard → Settings → Environment Variables:

Code
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=random-secret-string
NEXTAUTH_URL=https://your-app.vercel.app

Deploy

Code
# Zainstaluj Vercel CLI (opcjonalnie)
npm i -g vercel
 
# Deploy
vercel
 
# Lub połącz repo z GitHub — auto-deploy

Migracja produkcyjna

Code
npx prisma migrate deploy

FAQ

Czym jest Prisma i dlaczego warto go używać z Next.js?

Prisma to nowoczesny ORM dla Node.js i TypeScript, który generuje type-safe klienta bazodanowego na podstawie deklaratywnego schematu. W połączeniu z Next.js eliminuje konieczność pisania surowego SQL to język zapytań używany do pracy z relacyjnymi bazami danych. i zapewnia pełne autouzupełnianie w IDE — zmiany w schemacie natychmiast widać jako błędy TypeScript w kodzie aplikacji.

Jakie bazy danych obsługuje Prisma?

Prisma obsługuje PostgreSQL, MySQL, SQLite, SQL Server, MongoDB oraz CockroachDB. W tym tutorialu używamy PostgreSQL, który można uruchomić lokalnie przez Docker lub bezpłatnie w chmurze (Supabase, Railway, Neon). Do developmentu SQLite jest najwygodniejszy — nie wymaga osobnej instalacji.

Czym są migracje w Prisma i jak ich używać?

Migracje to wersjonowane zmiany schematu bazy danych. Polecenie npx prisma migrate dev tworzy nowy plik migracji na podstawie różnic w schema.prisma i aplikuje go do bazy. Na produkcji używasz npx prisma migrate deploy, które stosuje tylko zaakceptowane migracje bez modyfikacji schematu.

Jaka jest różnica między API Routes a Server Actions w Next.js?

API Routes (app/api/.../route.ts) tworzą klasyczne endpointy HTTP dostępne pod konkretnym URL, użyteczne gdy potrzebujesz publicznego API lub integrujesz zewnętrzne usługi. Server Actions to funkcje serwerowe wywoływane bezpośrednio z komponentów React bez tworzenia endpointu — idealne do mutacji danych w formularzach dzięki prostocie i bezpieczeństwu.

Jak działa Auth.js (NextAuth.js) z Prisma?

Auth.js integruje się z Prisma przez oficjalny @auth/prisma-adapter, który automatycznie zarządza tabelami użytkowników, kont i sesji w bazie danych. Po skonfigurowaniu adaptera Auth.js persystuje sesje i konta OAuth w Twojej bazie PostgreSQL. Sesja użytkownika jest dostępna w Server Components przez funkcję auth().

Jak deploy Next.js z Prisma na Vercel?

Ustaw zmienną DATABASE_URL w Vercel Dashboard → Settings → Environment Variables, a następnie dodaj do package.json skrypt "postinstall": "prisma generate", żeby klient Prisma był generowany automatycznie przy każdym deploymencie. Migracje produkcyjne uruchamiaj ręcznie poleceniem npx prisma migrate deploy.

Dlaczego używamy singleton pattern dla Prisma Client?

W trybie development Next.js często odświeża moduły przez Hot Module Replacement, co bez singleton pattern tworzyłoby dziesiątki połączeń z bazą danych. Pattern z globalThis zapewnia, że w całej aplikacji istnieje tylko jedna instancja PrismaClient, zarówno w development, jak i na produkcji.

Podsumowanie

Zbudowaliśmy:

  • ✅ Next.js z App Router
  • ✅ Prisma ORM z PostgreSQL
  • ✅ CRUD API (REST + Server Actions)
  • ✅ Frontend z React
  • ✅ Autoryzację z Auth.js
  • ✅ Deployment na Vercel

Następne kroki:

  • Dodaj walidację (Zod)
  • Dodaj testy
  • Dodaj kategorie / projekty
  • Dodaj deadline'y i powiadomienia

Prisma + Next.js to bardzo dobry stack na wejście w fullstack, ale warto od początku budować go z myślą o walidacji, auth i poprawnym modelu danych, a nie tylko o "szybkim CRUD-zie". Zacznij od prostego projektu, rozbudowuj iteracyjnie i traktuj tutorial jako punkt startu, nie gotową architekturę produkcyjną. Warto też poznać Server Actions — formularze bez endpointów API.

Źródła i dokumentacja


Chcesz więcej o backendzie? Sprawdź bazy danych SQL vs NoSQL lub poznaj serverless deployment na Vercel.

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab