Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🟣 Data, Server & APIs  ·  Lesson 23

Connect Next.js to MySQL

How Next.js talks to MySQL

Browserforms, buttons — never sees the DB passwordNext.js serverServer Components (SELECT) + Server Actions (INSERT/UPDATE/DELETE)lib/db.jsmysql2 connection poolMySQLedusite database
Only the server layer touches the database

Create the table

SQL – run in phpMyAdmin or mysql CLI
CREATE DATABASE edusite CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE edusite;

CREATE TABLE courses (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  slug       VARCHAR(80)  NOT NULL UNIQUE,
  title      VARCHAR(120) NOT NULL,
  level      ENUM('Beginner','Intermediate','Advanced') DEFAULT 'Beginner',
  fee        INT NOT NULL DEFAULT 0,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO courses (slug, title, level, fee) VALUES
('c-programming', 'C Programming', 'Beginner', 2499),
('python-ai', 'Python + AI', 'Beginner', 3499),
('nextjs', 'Next.js', 'Intermediate', 4499);
courses🔑 id INTslug VARCHAR(80) UNIQUEtitle VARCHAR(120)level ENUMfee INTcreated_at TIMESTAMP
courses table

Install mysql2 and set env vars

Terminal
npm install mysql2 server-only
ENV – .env.local
# .env.local  (never commit this file)
DB_HOST=localhost
DB_USER=edusite_user
DB_PASSWORD=Str0ng#Pass
DB_NAME=edusite

lib/db.js – the connection pool

JS – lib/db.js
// lib/db.js
import 'server-only'                     // build error if a client file imports this
import mysql from 'mysql2/promise'

// Re-use one pool across hot reloads in development
const globalForDb = globalThis
export const pool =
  globalForDb.mysqlPool ??
  mysql.createPool({
    host: process.env.DB_HOST,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_NAME,
    waitForConnections: true,
    connectionLimit: 5,
  })
if (process.env.NODE_ENV !== 'production') globalForDb.mysqlPool = pool

// Small helper: const rows = await query('SELECT ...', [params])
export async function query(sql, params = []) {
  const [rows] = await pool.execute(sql, params)
  return rows
}

READ: list courses

JSX – app/admin/courses/page.jsx
// app/admin/courses/page.jsx  (Server Component)
import Link from 'next/link'
import { query } from '@/lib/db'
import { deleteCourse } from './actions'

export const dynamic = 'force-dynamic'   // always show the latest rows

export default async function AdminCourses() {
  const courses = await query('SELECT id, slug, title, level, fee FROM courses ORDER BY id DESC')
  return (
    <main className="container">
      <h1>Courses ({courses.length})</h1>
      <Link href="/admin/courses/new" className="btn">+ Add course</Link>
      <table>
        <thead><tr><th>Title</th><th>Level</th><th>Fee</th><th></th></tr></thead>
        <tbody>
          {courses.map((c) => (
            <tr key={c.id}>
              <td>{c.title}</td><td>{c.level}</td><td>₹{c.fee}</td>
              <td>
                <Link href={`/admin/courses/${c.id}/edit`}>Edit</Link>{' '}
                <form action={deleteCourse} style={{ display: 'inline' }}>
                  <input type="hidden" name="id" value={c.id} />
                  <button>Delete</button>
                </form>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  )
}
🔒 localhost:3000/admin/coursesEduSite AdminEnquiriesCoursesCourses (3) [+ Add course]Next.js · Intermediate · ₹4499 Edit DeletePython + AI · Beginner · ₹3499 Edit DeleteC Programming · Beginner · ₹2499 Edit Delete

CREATE: add a course

JS – app/admin/courses/actions.js
// app/admin/courses/actions.js
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { query } from '@/lib/db'

function readCourse(formData) {
  const title = String(formData.get('title') || '').trim()
  const level = String(formData.get('level') || 'Beginner')
  const fee = Number(formData.get('fee'))
  const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
  const errors = {}
  if (title.length < 2) errors.title = 'Title is required'
  if (!['Beginner', 'Intermediate', 'Advanced'].includes(level)) errors.level = 'Invalid level'
  if (!Number.isInteger(fee) || fee < 0) errors.fee = 'Fee must be a whole number'
  return { data: { title, level, fee, slug }, errors }
}

export async function createCourse(prev, formData) {
  const { data, errors } = readCourse(formData)
  if (Object.keys(errors).length) return { errors }
  try {
    await query('INSERT INTO courses (slug, title, level, fee) VALUES (?, ?, ?, ?)',
                [data.slug, data.title, data.level, data.fee])
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') return { errors: { title: 'A course with this name exists' } }
    throw e
  }
  revalidatePath('/admin/courses')
  redirect('/admin/courses')
}

export async function updateCourse(id, prev, formData) {   // id bound with .bind()
  const { data, errors } = readCourse(formData)
  if (Object.keys(errors).length) return { errors }
  await query('UPDATE courses SET title = ?, level = ?, fee = ? WHERE id = ?',
              [data.title, data.level, data.fee, id])
  revalidatePath('/admin/courses')
  redirect('/admin/courses')
}

export async function deleteCourse(formData) {
  await query('DELETE FROM courses WHERE id = ?', [Number(formData.get('id'))])
  revalidatePath('/admin/courses')
}
JSX – app/admin/courses/new/page.jsx
// app/admin/courses/new/page.jsx
'use client'
import { useActionState } from 'react'
import { createCourse } from '../actions'

export default function NewCourse() {
  const [state, action, pending] = useActionState(createCourse, { errors: {} })
  const e = state.errors || {}
  return (
    <form action={action} className="container">
      <h1>Add course</h1>
      <input name="title" placeholder="Course title" />  {e.title && <small>{e.title}</small>}
      <select name="level">
        <option>Beginner</option><option>Intermediate</option><option>Advanced</option>
      </select>
      <input name="fee" type="number" placeholder="Fee in ₹" />  {e.fee && <small>{e.fee}</small>}
      <button disabled={pending}>{pending ? 'Saving…' : 'Save course'}</button>
    </form>
  )
}
Admin browserServer ActionMySQLsubmit title, level, feevalidateINSERT … VALUES (?, ?, ?, ?)OK (insertId)revalidatePath('/admin/courses')redirect → updated list
Create flow

UPDATE and DELETE

JSX – app/admin/courses/[id]/edit/page.jsx
// app/admin/courses/[id]/edit/page.jsx  (Server Component)
import { notFound } from 'next/navigation'
import { query } from '@/lib/db'
import { updateCourse } from '../../actions'

export default async function EditCourse({ params }) {
  const { id } = await params
  const [course] = await query('SELECT * FROM courses WHERE id = ?', [Number(id)])
  if (!course) notFound()
  // bind() pre-fills (id, prevState) so the form only passes formData
  const action = updateCourse.bind(null, course.id, {})
  return (
    <form action={action}>
      <input name="title" defaultValue={course.title} />
      <select name="level" defaultValue={course.level}>
        <option>Beginner</option><option>Intermediate</option><option>Advanced</option>
      </select>
      <input name="fee" type="number" defaultValue={course.fee} />
      <button>Update</button>
    </form>
  )
}

Delete uses a tiny form with a hidden id (see the list page above) — no JavaScript needed.

SQL injection – the golden rule

JS – placeholders
// ❌ NEVER – user input glued into SQL
query(`SELECT * FROM users WHERE email = '${email}'`)
// email = "' OR '1'='1"  → returns ALL users!

// ✅ ALWAYS – placeholders; mysql2 escapes the values
query('SELECT * FROM users WHERE email = ?', [email])

Which hosting supports this?

You need a host that runs Node.js and can reach your MySQL server: a VPS (Hostinger VPS, DigitalOcean), Hostinger's Node.js web apps, Railway, Render, or Vercel with a cloud MySQL (PlanetScale-compatible, Aiven, TiDB). Plain shared PHP hosting cannot run a Next.js server. See the Deploy lesson.

Common mistakes

  • Creating a new connection inside every function → "Too many connections". Use one pool.
  • Committing .env.local to GitHub → password leaked. It is in .gitignore by default.
  • Forgetting revalidatePath → the list still shows old data.
  • Using localhost as DB_HOST on Vercel → the database must be reachable over the internet.

Summary

  • mysql2 pool in lib/db.js (server-only), credentials in .env.local.
  • SELECT in Server Components; INSERT/UPDATE/DELETE in Server Actions + revalidatePath.
  • Always use ? placeholders.
✅ Practice

Create an enquiries table and change the EduSite contact Server Action to INSERT into MySQL instead of the JSON file. Show all enquiries on /admin/enquiries.

Frequently Asked Questions

Can I connect to MySQL from a Client Component?
No. Database credentials must stay on the server. Query MySQL only in Server Components, Server Actions or Route Handlers.
Why use a connection pool?
Opening a new MySQL connection for every request is slow and can hit the connection limit. A pool keeps a few connections open and reuses them.
Should I use an ORM like Prisma or Drizzle?
Both work well with Next.js. This lesson uses plain SQL with mysql2 so you understand exactly what runs; you can switch to an ORM later.
← Next.js Tutorial
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicLogin & Authentication Your progress: 23 / 37 (62%) · Lesson 24 / 37 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free