This guide shows you exactly how to migrate from raw SQL to Prisma, one endpoint at a time.
- Prisma installed
- Schema created
- Client generated
- Both Neon and Prisma available
- GET /api/superadmin/users
- GET /api/superadmin/groups
- GET /api/superadmin/milestones
- GET /api/people (new converts)
- GET /api/progress
- GET /api/attendance
- POST endpoints (create)
- PUT/PATCH endpoints (update)
- DELETE endpoints
- Remove unused Neon imports
- Update tests
- Final verification
Current Code (Neon):
import { query } from '@/lib/neon';
const result = await query(
\`SELECT
id, username, role, first_name, last_name, email,
created_at, updated_at
FROM users
WHERE username NOT IN ('skaduteye', 'sysadmin')
ORDER BY created_at DESC\`,
[]
);
return NextResponse.json(result.rows);New Code (Prisma):
import { prisma } from '@/lib/prisma';
const users = await prisma.user.findMany({
where: {
username: {
notIn: ['skaduteye', 'sysadmin']
}
},
select: {
id: true,
username: true,
role: true,
firstName: true,
lastName: true,
email: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc'
}
});
return NextResponse.json(users);Action Items:
- Replace
import { query } from '@/lib/neon'withimport { prisma } from '@/lib/prisma' - Convert SQL query to Prisma query (see above)
- Test the endpoint
- Verify data matches
Current Code (SQL with JOIN):
const result = await query(
\`SELECT
nc.id, nc.first_name, nc.last_name, nc.phone_number,
g.name as group_name, g.year as group_year,
u.username as registered_by_name
FROM new_converts nc
LEFT JOIN groups g ON nc.group_id = g.id
LEFT JOIN users u ON nc.registered_by = u.id
WHERE nc.group_id = $1
ORDER BY nc.created_at DESC\`,
[groupId]
);New Code (Prisma with include):
const converts = await prisma.newConvert.findMany({
where: {
groupId: groupId
},
include: {
group: {
select: {
name: true,
year: true,
}
},
registeredBy: {
select: {
username: true,
}
}
},
orderBy: {
createdAt: 'desc'
}
});
// Transform to match old format if needed
const formatted = converts.map(c => ({
id: c.id,
first_name: c.firstName,
last_name: c.lastName,
phone_number: c.phoneNumber,
group_name: c.group?.name,
group_year: c.group?.year,
registered_by_name: c.registeredBy?.username,
}));Current Code:
const result = await query(
\`INSERT INTO new_converts
(first_name, last_name, phone_number, group_id, registered_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *\`,
[firstName, lastName, phoneNumber, groupId, userId]
);
return NextResponse.json(result.rows[0]);New Code:
const newConvert = await prisma.newConvert.create({
data: {
firstName,
lastName,
phoneNumber,
groupId,
registeredById: userId,
},
include: {
group: true,
registeredBy: {
select: {
username: true,
}
}
}
});
return NextResponse.json(newConvert);Current Code:
const result = await query(
\`UPDATE users
SET first_name = $1, last_name = $2, email = $3, updated_at = NOW()
WHERE id = $4
RETURNING *\`,
[firstName, lastName, email, userId]
);New Code:
const updatedUser = await prisma.user.update({
where: { id: userId },
data: {
firstName,
lastName,
email,
// updatedAt automatically managed by @updatedAt
}
});
return NextResponse.json(updatedUser);Current Code:
const client = await pool.connect();
try {
await client.query('BEGIN');
const convertResult = await client.query(
'INSERT INTO new_converts (...) VALUES (...) RETURNING id',
[...]
);
await client.query(
'INSERT INTO progress_records (...) VALUES (...)',
[...]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}New Code:
const result = await prisma.$transaction(async (tx) => {
const convert = await tx.newConvert.create({
data: { /* ... */ }
});
await tx.progressRecord.createMany({
data: milestones.map(m => ({
personId: convert.id,
stageNumber: m.stageNumber,
stageName: m.stageName,
}))
});
return convert;
});-
Milestones (simplest - no relations)
- GET /api/superadmin/milestones
-
Groups (simple relations)
- GET /api/superadmin/groups
- GET /api/groups
-
Users (medium complexity)
- GET /api/superadmin/users
- GET /api/users
-
New Converts (multiple relations)
- GET /api/people
- GET /api/people/[id]
-
Progress & Attendance (complex queries)
- GET /api/progress
- GET /api/attendance
-
Create Operations
- POST /api/people (create convert)
- POST /api/groups
-
Update Operations
- PATCH /api/people/[id]
- PUT /api/users/[id]
-
Delete Operations
- DELETE endpoints (migrate last)
- Final verification
- Remove Neon imports
- Update documentation
For each migrated endpoint:
// Temporarily keep both for testing
const oldResult = await query('SELECT * FROM users');
const newResult = await prisma.user.findMany();
console.log('Old count:', oldResult.rows.length);
console.log('New count:', newResult.length);
// Verify they match!- Create a test collection
- Test BEFORE migration
- Test AFTER migration
- Compare responses
import { prisma } from '@/lib/prisma';
describe('User API', () => {
it('should fetch all users', async () => {
const users = await prisma.user.findMany();
expect(users).toBeDefined();
expect(Array.isArray(users)).toBe(true);
});
});// SQL: WHERE role = 'admin' AND deleted_at IS NULL
// Prisma:
where: {
role: 'admin',
deletedAt: null
}// SQL: ORDER BY created_at DESC
// Prisma:
orderBy: { createdAt: 'desc' }// SQL: LIMIT 10 OFFSET 20
// Prisma:
take: 10,
skip: 20// SQL: SELECT COUNT(*) FROM users
// Prisma:
const count = await prisma.user.count()// SQL: WHERE first_name ILIKE '%john%'
// Prisma:
where: {
firstName: {
contains: 'john',
mode: 'insensitive'
}
}Problem: SQL uses snake_case, Prisma uses camelCase
// SQL column: first_name
// Prisma field: firstNameSolution: Schema has @map("first_name") - Prisma handles this automatically!
// β Wrong
where: { email: undefined }
// β
Correct
where: { email: null }// Old: result.rows (array)
// New: direct array
// If you need to match old structure:
return NextResponse.json({ rows: users });// SQL returns strings, Prisma returns Date objects
// Frontend might expect strings:
const formatted = users.map(u => ({
...u,
createdAt: u.createdAt.toISOString()
}));Create a simple spreadsheet or GitHub issue:
| Endpoint | Status | Date | Notes |
|---|---|---|---|
| GET /api/superadmin/users | β Done | 2026-01-18 | Working |
| POST /api/people | π In Progress | Testing | |
| GET /api/progress | β³ Pending | Next |
- Pick one simple endpoint (recommendation: GET /api/superadmin/milestones)
- Create a backup branch:
git checkout -b migrate/milestones-endpoint - Migrate the endpoint following Example 1
- Test thoroughly with Postman/curl
- Commit if successful:
git commit -m "Migrate milestones endpoint to Prisma" - Merge to main after testing
- Repeat for next endpoint
If you get stuck:
- Check
PRISMA_MIGRATION_EXAMPLES.tsfor patterns - Review Prisma docs: https://www.prisma.io/docs
- Use Prisma Studio to explore data:
npm run prisma:studio - Compare old vs new query results side-by-side
// src/app/api/your-endpoint/route.ts
// OLD:
// import { query } from '@/lib/neon';
// NEW:
import { prisma } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
// OLD:
// const result = await query('SELECT * FROM table', []);
// return NextResponse.json(result.rows);
// NEW:
const data = await prisma.table.findMany();
return NextResponse.json(data);
} catch (error) {
console.error('Error:', error);
return NextResponse.json(
{ error: 'Failed to fetch data' },
{ status: 500 }
);
}
}Remember: You can always revert if something breaks! Git is your friend. π