CRUD con Next.js y MySQL

Aprende a crear un sistema CRUD completo con Next.js y MySQL paso a paso. Esta guía usa JavaScript puro con ejemplos sencillos y el menor código posible.

1. Configuración Inicial

Crear Proyecto y Dependencias

bash
# Crear proyecto Next.js
npx create-next-app@latest mi-crud-app
cd mi-crud-app

# Instalar dependencias para MySQL
npm install mysql2
npm install @next/env

# Instalar dependencias adicionales
npm install axios

Estructura de Carpetas

text
mi-crud-app/
├── app/
│   ├── api/
│   │   └── users/
│   │       ├── route.js
│   │       └── [id]/
│   │           └── route.js
│   ├── users/
│   │   ├── page.js
│   │   ├── create/
│   │   │   └── page.js
│   │   └── edit/
│   │       └── [id]/
│   │           └── page.js
│   ├── globals.css
│   ├── layout.js
│   └── page.js
├── lib/
│   └── db.js
├── components/
│   ├── UserForm.js
│   └── UserList.js
└── .env.local

2. Configuración de Base de Datos

Variables de Entorno

bash
# .env.local
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=tu_password
DB_NAME=crud_nextjs

Conexión a MySQL

javascript
// lib/db.js
import mysql from 'mysql2/promise'

const connection = 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: 10,
queueLimit: 0
})

export default connection

Crear Tabla en MySQL

sql
-- Ejecutar en MySQL
CREATE DATABASE crud_nextjs;
USE crud_nextjs;

CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
age INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insertar datos de prueba
INSERT INTO users (name, email, age) VALUES 
('Juan Pérez', 'juan@email.com', 25),
('María García', 'maria@email.com', 30),
('Carlos López', 'carlos@email.com', 28);
 

Asegúrate de tener MySQL instalado y corriendo en tu sistema. Crea la base de datos antes de ejecutar la aplicación.

3. API Routes - CRUD Completo

Listar y Crear Usuarios

javascript
// app/api/users/route.js
import db from '@/lib/db'
import { NextResponse } from 'next/server'

// GET - Obtener todos los usuarios
export async function GET() {
try {
  const [rows] = await db.execute('SELECT * FROM users ORDER BY created_at DESC')
  return NextResponse.json(rows)
} catch (error) {
  return NextResponse.json(
    { error: 'Error al obtener usuarios' }, 
    { status: 500 }
  )
}
}

// POST - Crear nuevo usuario
export async function POST(request) {
try {
  const { name, email, age } = await request.json()
  
  const [result] = await db.execute(
    'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
    [name, email, age]
  )
  
  return NextResponse.json(
    { id: result.insertId, name, email, age },
    { status: 201 }
  )
} catch (error) {
  if (error.code === 'ER_DUP_ENTRY') {
    return NextResponse.json(
      { error: 'El email ya existe' },
      { status: 400 }
    )
  }
  return NextResponse.json(
    { error: 'Error al crear usuario' },
    { status: 500 }
  )
}
}

Obtener, Actualizar y Eliminar Usuario

javascript
// app/api/users/[id]/route.js
import db from '@/lib/db'
import { NextResponse } from 'next/server'

// GET - Obtener usuario por ID
export async function GET(request, { params }) {
try {
  const [rows] = await db.execute('SELECT * FROM users WHERE id = ?', [params.id])
  
  if (rows.length === 0) {
    return NextResponse.json(
      { error: 'Usuario no encontrado' },
      { status: 404 }
    )
  }
  
  return NextResponse.json(rows[0])
} catch (error) {
  return NextResponse.json(
    { error: 'Error al obtener usuario' },
    { status: 500 }
  )
}
}

// PUT - Actualizar usuario
export async function PUT(request, { params }) {
try {
  const { name, email, age } = await request.json()
  
  const [result] = await db.execute(
    'UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?',
    [name, email, age, params.id]
  )
  
  if (result.affectedRows === 0) {
    return NextResponse.json(
      { error: 'Usuario no encontrado' },
      { status: 404 }
    )
  }
  
  return NextResponse.json({ id: params.id, name, email, age })
} catch (error) {
  if (error.code === 'ER_DUP_ENTRY') {
    return NextResponse.json(
      { error: 'El email ya existe' },
      { status: 400 }
    )
  }
  return NextResponse.json(
    { error: 'Error al actualizar usuario' },
    { status: 500 }
  )
}
}

// DELETE - Eliminar usuario
export async function DELETE(request, { params }) {
try {
  const [result] = await db.execute('DELETE FROM users WHERE id = ?', [params.id])
  
  if (result.affectedRows === 0) {
    return NextResponse.json(
      { error: 'Usuario no encontrado' },
      { status: 404 }
    )
  }
  
  return NextResponse.json({ message: 'Usuario eliminado correctamente' })
} catch (error) {
  return NextResponse.json(
    { error: 'Error al eliminar usuario' },
    { status: 500 }
  )
}
}

4. Componentes Frontend

Lista de Usuarios

javascript
// components/UserList.js
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'

export default function UserList() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)

useEffect(() => {
  fetchUsers()
}, [])

const fetchUsers = async () => {
  try {
    const res = await fetch('/api/users')
    const data = await res.json()
    setUsers(data)
  } catch (error) {
    console.error('Error:', error)
  } finally {
    setLoading(false)
  }
}

const deleteUser = async (id) => {
  if (!confirm('¿Estás seguro de eliminar este usuario?')) return
  
  try {
    const res = await fetch(`/api/users/${id}`, { method: 'DELETE' })
    if (res.ok) {
      setUsers(users.filter(user => user.id !== id))
    }
  } catch (error) {
    console.error('Error:', error)
  }
}

if (loading) return <div className="text-center py-4">Cargando...</div>

return (
  <div className="max-w-4xl mx-auto p-6">
    <div className="flex justify-between items-center mb-6">
      <h1 className="text-2xl font-bold">Lista de Usuarios</h1>
      <Link 
        href="/users/create"
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
      >
        Nuevo Usuario
      </Link>
    </div>

    <div className="bg-white shadow rounded-lg overflow-hidden">
      <table className="min-w-full">
        <thead className="bg-gray-50">
          <tr>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
              ID
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
              Nombre
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
              Email
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
              Edad
            </th>
            <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
              Acciones
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-gray-200">
          {users.map((user) => (
            <tr key={user.id}>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {user.id}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {user.name}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {user.email}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
                {user.age}
              </td>
              <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
                <Link
                  href={`/users/edit/${user.id}`}
                  className="text-blue-600 hover:text-blue-900 mr-4"
                >
                  Editar
                </Link>
                <button
                  onClick={() => deleteUser(user.id)}
                  className="text-red-600 hover:text-red-900"
                >
                  Eliminar
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  </div>
)
}

Formulario de Usuario

javascript
// components/UserForm.js
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'

export default function UserForm({ user = null, isEdit = false }) {
const router = useRouter()
const [formData, setFormData] = useState({
  name: user?.name || '',
  email: user?.email || '',
  age: user?.age || ''
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')

const handleChange = (e) => {
  setFormData({
    ...formData,
    [e.target.name]: e.target.value
  })
}

const handleSubmit = async (e) => {
  e.preventDefault()
  setLoading(true)
  setError('')

  try {
    const url = isEdit ? `/api/users/${user.id}` : '/api/users'
    const method = isEdit ? 'PUT' : 'POST'

    const res = await fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    })

    const data = await res.json()

    if (res.ok) {
      router.push('/users')
    } else {
      setError(data.error || 'Error al guardar usuario')
    }
  } catch (error) {
    setError('Error de conexión')
  } finally {
    setLoading(false)
  }
}

return (
  <div className="max-w-md mx-auto p-6">
    <h1 className="text-2xl font-bold mb-6">
      {isEdit ? 'Editar Usuario' : 'Crear Usuario'}
    </h1>

    {error && (
      <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
        {error}
      </div>
    )}

    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Nombre
        </label>
        <input
          type="text"
          name="name"
          value={formData.name}
          onChange={handleChange}
          required
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Email
        </label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          required
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 mb-1">
          Edad
        </label>
        <input
          type="number"
          name="age"
          value={formData.age}
          onChange={handleChange}
          min="1"
          max="120"
          className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      <div className="flex space-x-4">
        <button
          type="submit"
          disabled={loading}
          className="flex-1 bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 disabled:opacity-50"
        >
          {loading ? 'Guardando...' : (isEdit ? 'Actualizar' : 'Crear')}
        </button>
        
        <button
          type="button"
          onClick={() => router.push('/users')}
          className="flex-1 bg-gray-500 text-white py-2 px-4 rounded-md hover:bg-gray-600"
        >
          Cancelar
        </button>
      </div>
    </form>
  </div>
)
}

5. Páginas del Frontend

Página Principal de Usuarios

javascript
// app/users/page.js
import UserList from '@/components/UserList'

export default function UsersPage() {
return <UserList />
}

Página Crear Usuario

javascript
// app/users/create/page.js
import UserForm from '@/components/UserForm'

export default function CreateUserPage() {
return <UserForm />
}

Página Editar Usuario

javascript
// app/users/edit/[id]/page.js
async function getUser(id) {
try {
  const res = await fetch(`http://localhost:3000/api/users/${id}`, {
    cache: 'no-store'
  })
  if (!res.ok) return null
  return await res.json()
} catch (error) {
  return null
}
}

export default async function EditUserPage({ params }) {
const user = await getUser(params.id)

if (!user) {
  return (
    <div className="max-w-md mx-auto p-6 text-center">
      <h1 className="text-2xl font-bold text-red-600">Usuario no encontrado</h1>
    </div>
  )
}

return <UserForm user={user} isEdit={true} />
}

Página Principal con Navegación

javascript
// app/page.js
import Link from 'next/link'

export default function HomePage() {
return (
  <div className="min-h-screen bg-gray-100">
    <div className="max-w-4xl mx-auto py-12 px-6">
      <div className="text-center">
        <h1 className="text-4xl font-bold text-gray-900 mb-8">
          CRUD con Next.js y MySQL
        </h1>
        <p className="text-xl text-gray-600 mb-12">
          Sistema completo de gestión de usuarios
        </p>
        
        <div className="bg-white rounded-lg shadow-lg p-8">
          <h2 className="text-2xl font-semibold mb-6">Funcionalidades</h2>
          
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
            <div className="text-left">
              <h3 className="font-semibold text-lg mb-2">✅ Crear Usuarios</h3>
              <p className="text-gray-600">Agregar nuevos usuarios con validación</p>
            </div>
            
            <div className="text-left">
              <h3 className="font-semibold text-lg mb-2">📋 Listar Usuarios</h3>
              <p className="text-gray-600">Ver todos los usuarios en tabla</p>
            </div>
            
            <div className="text-left">
              <h3 className="font-semibold text-lg mb-2">✏️ Editar Usuarios</h3>
              <p className="text-gray-600">Actualizar información existente</p>
            </div>
            
            <div className="text-left">
              <h3 className="font-semibold text-lg mb-2">🗑️ Eliminar Usuarios</h3>
              <p className="text-gray-600">Borrar usuarios con confirmación</p>
            </div>
          </div>
          
          <Link
            href="/users"
            className="inline-block bg-blue-500 text-white px-8 py-3 rounded-lg text-lg font-semibold hover:bg-blue-600 transition-colors"
          >
            Ver Usuarios
          </Link>
        </div>
      </div>
    </div>
  </div>
)
}

6. Layout y Estilos

Layout Principal

javascript
// app/layout.js
import './globals.css'

export const metadata = {
title: 'CRUD Next.js MySQL',
description: 'Sistema CRUD con Next.js y MySQL',
}

export default function RootLayout({ children }) {
return (
  <html lang="es">
    <body>
      <nav className="bg-blue-600 text-white p-4">
        <div className="max-w-4xl mx-auto flex justify-between items-center">
          <h1 className="text-xl font-bold">
            <a href="/">CRUD App</a>
          </h1>
          <div className="space-x-4">
            <a href="/" className="hover:text-blue-200">Inicio</a>
            <a href="/users" className="hover:text-blue-200">Usuarios</a>
            <a href="/users/create" className="hover:text-blue-200">Crear Usuario</a>
          </div>
        </div>
      </nav>
      <main className="min-h-screen bg-gray-100">
        {children}
      </main>
    </body>
  </html>
)
}

Estilos Globales

css
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

* {
box-sizing: border-box;
padding: 0;
margin: 0;
}

html,
body {
max-width: 100vw;
overflow-x: hidden;
}

body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
    to bottom,
    transparent,
    rgb(var(--background-end-rgb))
  )
  rgb(var(--background-start-rgb));
}

a {
color: inherit;
text-decoration: none;
}

@media (prefers-color-scheme: dark) {
html {
  color-scheme: dark;
}
}

7. Ejecutar la Aplicación

Comandos de Ejecución

bash
# Instalar dependencias
npm install

# Ejecutar en desarrollo
npm run dev

# Abrir en el navegador
# http://localhost:3000

Verificar Funcionamiento

text
1. ✅ Ir a http://localhost:3000
2. ✅ Hacer clic en "Ver Usuarios"
3. ✅ Crear un nuevo usuario
4. ✅ Editar usuario existente
5. ✅ Eliminar usuario
6. ✅ Verificar datos en MySQL
 

Este CRUD usa conexión pool de MySQL para mejor rendimiento. Las consultas están optimizadas y usan prepared statements para seguridad.

 

Para producción, agrega validación en el servidor, paginación en la lista de usuarios y manejo de errores más robusto.

 

Recuerda configurar las variables de entorno correctamente y tener MySQL corriendo antes de ejecutar la aplicación.