CRUD Completo con Next.js 14 App Router 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.

1Configuració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

2Configuració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);
  Importante

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

3API 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 }
  )
}
}

4Componentes 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>
)
}

5Pá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>
)
}

6Layout 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;
}
}

7Ejecutar 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.

  Importante

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