From n00b to ZeroCool / Origen
APIs: cuando tu frontend aprende a hablar
Arma una .NET 8 Web API con Dapper y MySQL, mete JWT auth, contratos claros y tips de deploy para tu React + Vite + Tailwind.
Lo que vale la pena leer aquí
Pero por dentro es puro cartón.
Intro con gancho
Son las 11:47 pm. Tu React con Vite y Tailwind ya se ve perrón: cards, botones con hover, hasta un loading skeleton que parece de app “pro”.
Pero por dentro es puro cartón.
Los datos viven en un const, el “login” solo cambia un state, y el botón de Guardar es un console.log("todo bien"). Y justo cuando ya te ibas a dormir, cae el mensaje del jefe/cliente:
“¿Ya está conectado a la base?”
Ahí se pone real el asunto. Tu frontend tiene que aprender a hablar… pero con orden: APIs, contratos, auth, errores que no den pena y un deploy que aguante cuando abran la app desde el WiFi del Oxxo.
Qué vas a aprender
- Qué es una API en la vida real y por qué es el idioma entre tu React y tu backend.
- Cómo levantar una .NET 8 Web API con endpoints limpios.
- Cómo pegarle a datos con Dapper + MySQL sin meterte a un ORM gigante.
- Una base sólida de auth con JWT (login, claims y endpoints protegidos).
- Cómo versionar tu DB con SQL scripts para que tu “yo del futuro” no sufra.
- Tips de deploy para que production no sea ruleta rusa.
Contexto práctico
Vas a armar una API mínima pero de jale: un Task Tracker. No es glamoroso, pero es justo el tipo de sistema que te cae cuando eres freelance y lo cotizaste barato, o cuando un negocio solo necesita que “funcione y ya”.
Endpoints que sí se usan:
POST /api/auth/loginpara sacar token.- CRUD de tareas.
- Tareas por usuario (para no ver lo del compa por accidente).
Stack canónico:
- Frontend: React + Vite + Tailwind (solo consume la API).
- Backend: .NET 8 Web API.
- Datos: MySQL + Dapper.
- DB versionada: scripts SQL por orden.
Decisión clave: tu API no está para “verse elegante”. Está para ser predecible. Si el contrato cambia a media semana, el bug no es “del frontend”: es de todo el workflow.
Paso a paso (guía principal)
1) Contrato primero: endpoints y respuestas que no se muevan
Antes de abrir el editor, amarra un mini-contrato. Nada de documento eterno: un acuerdo corto que no se traicione.
Auth
POST /api/auth/login- Body:
{ "email": "...", "password": "..." } - 200:
{ "token": "...", "user": { "id": 1, "email": "..." } } - 401:
{ "error": "invalid_credentials" }
- Body:
Tasks (protegido)
GET /api/tasks→ lista del usuarioPOST /api/tasks→ creaPUT /api/tasks/{id}→ actualizaDELETE /api/tasks/{id}→ borra
Regla de oro: errores consistentes. Si un día mandas { error } y al siguiente { message }, el frontend va a fallar en production justo en tu deadline.
2) Crea el proyecto .NET 8 Web API
dotnet new webapi -n ByteIt.TasksApi
cd ByteIt.TasksApi
Instala paquetes:
dotnet add package Dapper
dotnet add package MySqlConnector
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Tip de talacha: si estás en una laptop ya cansada y el restore tarda siglos, revisa que no estés en VPN del jale o que el antivirus no esté escaneando todo el nuget cache. Sí pasa.
3) Base de datos con scripts SQL versionados
En el repo:
/db
/scripts
001_init.sql
002_seed.sql
001_init.sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
is_done TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_tasks_user ON tasks(user_id);
002_seed.sql
INSERT INTO users (email, password_hash)
VALUES ('demo@byteit.mx', '$2a$11$REEMPLAZA_CON_UN_HASH_REAL');
INSERT INTO tasks (user_id, title, is_done)
VALUES (1, 'Primera tarea', 0), (1, 'Pagar el internet', 1);
Tradeoff real: para seed “bien” necesitas hashing. Si todavía no metes BCrypt, puedes crear un usuario demo desde código solo en local, pero no te acostumbres. El día que alguien deje passwords en texto plano, ese “MVP” se vuelve incidente.
4) Configura conexión a MySQL en appsettings
En appsettings.Development.json:
{
"ConnectionStrings": {
"Default": "Server=localhost;Port=3306;Database=byteit_tasks;User=root;Password=tu_password;"
},
"Jwt": {
"Issuer": "byteit",
"Audience": "byteit",
"Key": "CAMBIA-ESTO-POR-UNA-CLAVE-LARGA-Y-SEGURA"
}
}
Pro tip: Jwt:Key no debería vivir en Git. Para avanzar, va. Para deploy, usa variables de entorno o secret manager.
5) Crea un factory de conexión (simple, sin humo)
/Data/Db.cs
using System.Data;
using MySqlConnector;
namespace ByteIt.TasksApi.Data;
public class Db
{
private readonly IConfiguration _config;
public Db(IConfiguration config) => _config = config;
public IDbConnection CreateConnection()
=> new MySqlConnection(_config.GetConnectionString("Default"));
}
Registra en Program.cs:
using ByteIt.TasksApi.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSingleton<Db>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
6) Dapper repos: queries claras, cero magia
Modelos:
/Models/User.cs
namespace ByteIt.TasksApi.Models;
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
/Models/TaskItem.cs
namespace ByteIt.TasksApi.Models;
public class TaskItem
{
public int Id { get; set; }
public int UserId { get; set; }
public string Title { get; set; } = "";
public bool IsDone { get; set; }
}
Repo TasksRepo:
/Data/TasksRepo.cs
using Dapper;
using ByteIt.TasksApi.Models;
namespace ByteIt.TasksApi.Data;
public class TasksRepo
{
private readonly Db _db;
public TasksRepo(Db db) => _db = db;
public async Task<IEnumerable<TaskItem>> GetByUserId(int userId)
{
const string sql = @"SELECT id, user_id AS UserId, title, is_done AS IsDone
FROM tasks
WHERE user_id = @UserId
ORDER BY id DESC";
using var conn = _db.CreateConnection();
return await conn.QueryAsync<TaskItem>(sql, new { UserId = userId });
}
public async Task<int> Create(int userId, string title)
{
const string sql = @"INSERT INTO tasks (user_id, title, is_done)
VALUES (@UserId, @Title, 0);
SELECT LAST_INSERT_ID();";
using var conn = _db.CreateConnection();
return await conn.ExecuteScalarAsync<int>(sql, new { UserId = userId, Title = title });
}
public async Task<bool> Update(int id, int userId, string title, bool isDone)
{
const string sql = @"UPDATE tasks
SET title = @Title, is_done = @IsDone
WHERE id = @Id AND user_id = @UserId";
using var conn = _db.CreateConnection();
var rows = await conn.ExecuteAsync(sql, new { Id = id, UserId = userId, Title = title, IsDone = isDone });
return rows == 1;
}
public async Task<bool> Delete(int id, int userId)
{
const string sql = @"DELETE FROM tasks WHERE id = @Id AND user_id = @UserId";
using var conn = _db.CreateConnection();
var rows = await conn.ExecuteAsync(sql, new { Id = id, UserId = userId });
return rows == 1;
}
}
Registra el repo:
builder.Services.AddScoped<TasksRepo>();
Decisión defensiva: en writes siempre validas user_id. Esto te evita el clásico bug de producción: “si adivinas un ID, borras cosas que no son tuyas”.

7) Auth con JWT (login realista)
Auth se puede volver pantano. Aquí va lo mínimo que sí aguanta: validar credenciales, emitir JWT con userId y proteger endpoints.
Configura JWT en Program.cs:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var jwtKey = builder.Configuration["Jwt:Key"]!;
var keyBytes = Encoding.UTF8.GetBytes(jwtKey);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
Y en el pipeline:
app.UseAuthentication();
app.UseAuthorization();
Repo de usuarios:
/Data/UsersRepo.cs
using Dapper;
using ByteIt.TasksApi.Models;
namespace ByteIt.TasksApi.Data;
public class UsersRepo
{
private readonly Db _db;
public UsersRepo(Db db) => _db = db;
public async Task<User?> GetByEmail(string email)
{
const string sql = @"SELECT id, email, password_hash AS PasswordHash
FROM users
WHERE email = @Email
LIMIT 1";
using var conn = _db.CreateConnection();
return await conn.QueryFirstOrDefaultAsync<User>(sql, new { Email = email });
}
}
Registra:
builder.Services.AddScoped<UsersRepo>();
Controller de auth:
/Controllers/AuthController.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using ByteIt.TasksApi.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace ByteIt.TasksApi.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private readonly UsersRepo _users;
private readonly IConfiguration _config;
public AuthController(UsersRepo users, IConfiguration config)
{
_users = users;
_config = config;
}
public record LoginRequest(string Email, string Password);
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var user = await _users.GetByEmail(req.Email);
if (user is null)
return Unauthorized(new { error = "invalid_credentials" });
// Demo: compara contra un hash real.
// Recomendación: usa BCrypt para validar. (Aquí se deja el hook listo.)
var isValid = FakeVerify(req.Password, user.PasswordHash);
if (!isValid)
return Unauthorized(new { error = "invalid_credentials" });
var token = CreateToken(user.Id, user.Email);
return Ok(new { token, user = new { id = user.Id, email = user.Email } });
}
private string CreateToken(int userId, string email)
{
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim("uid", userId.ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _config["Jwt:Issuer"],
audience: _config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(8),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private bool FakeVerify(string password, string passwordHash)
{
// Sustituye por BCrypt.Verify(password, passwordHash)
return passwordHash == password;
}
}
Sí, FakeVerify es trampa con letrero. Déjalo solo como placeholder local. Si eso llega a production, te estás aventando un bug de seguridad que luego cuesta días (y vergüenza) hacer rollback.
8) Endpoints protegidos: TasksController
/Controllers/TasksController.cs
using System.Security.Claims;
using ByteIt.TasksApi.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ByteIt.TasksApi.Controllers;
[ApiController]
[Route("api/tasks")]
[Authorize]
public class TasksController : ControllerBase
{
private readonly TasksRepo _tasks;
public TasksController(TasksRepo tasks) => _tasks = tasks;
private int UserId()
{
var uid = User.FindFirstValue("uid") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
return int.Parse(uid!);
}
public record CreateTaskRequest(string Title);
public record UpdateTaskRequest(string Title, bool IsDone);
[HttpGet]
public async Task<IActionResult> Get()
{
var items = await _tasks.GetByUserId(UserId());
return Ok(items);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTaskRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest(new { error = "title_required" });
var id = await _tasks.Create(UserId(), req.Title.Trim());
return Created($"/api/tasks/{id}", new { id });
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateTaskRequest req)
{
var ok = await _tasks.Update(id, UserId(), req.Title.Trim(), req.IsDone);
if (!ok) return NotFound(new { error = "not_found" });
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var ok = await _tasks.Delete(id, UserId());
if (!ok) return NotFound(new { error = "not_found" });
return NoContent();
}
}
Decisión práctica: Created(...) y NoContent() le quitan dudas al frontend. Menos “a ver qué regresó” y más workflow estable.
9) Consumo desde React: fetch con token (sin dramas)
En tu frontend crea un mini cliente.
/src/lib/api.ts
const API_URL = import.meta.env.VITE_API_URL;
export async function apiFetch(path: string, options: RequestInit = {}) {
const token = localStorage.getItem("token");
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
const error = payload?.error || `http_${res.status}`;
throw new Error(error);
}
// 204 no trae body
if (res.status === 204) return null;
return res.json();
}
Login:
export async function login(email: string, password: string) {
const data = await apiFetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
localStorage.setItem("token", data.token);
return data.user;
}
Listar tasks:
export async function getTasks() {
return apiFetch("/api/tasks");
}
Escena real: lo pruebas en el cel y truena porque localhost en el teléfono no es tu compu. Ajusta VITE_API_URL a tu host real o a la IP local. Y sí: el router a veces se pone creativo.
10) Deploy básico (sin vender humo)
Checklist para no terminar haciendo deploy a las 2 am con café frío:
- CORS: si frontend y API viven en dominios/puertos distintos, tienes que permitir el origin correcto.
- Variables de entorno:
ConnectionStrings__Default,Jwt__Key. - HTTPS: en prod, es de ley.
- Logs: mínimo que tu host capture consola y no te deje ciego.
CORS en Program.cs (mínimo):
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
{
policy.WithOrigins("https://tu-frontend.com")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
// ...
app.UseCors("Frontend");
Tradeoff real: AllowAnyOrigin() “arregla” rápido, pero luego alguien lo deja así y tu API queda abierta para cualquier sitio. Amarra el origin y duermes mejor.

Screenshots sugeridos
- Swagger UI mostrando
POST /api/auth/loginyGET /api/taskscon el candadito de[Authorize]. - Postman/Insomnia con request de login y cómo se copia el
Bearer token. - Terminal con
dotnet runy logs de requests. - Frontend: login simple y lista de tareas cargando desde API.
- Error visible y manejado: un toast con
invalid_credentialsotitle_required(sin stacktrace feo).
Errores comunes + solución
1) “CORS error” y el frontend jura que es bug de React
Síntoma: en consola sale bloqueo por CORS.
Solución: habilita CORS con el origin exacto del frontend y pon app.UseCors(...) antes de mapear controllers. Revisa el http/https y el puerto.
2) 401 aunque “sí mandé el token”
Síntoma: GET /api/tasks regresa 401.
Solución: confirma header:Authorization: Bearer <token>
Y que el orden sea:app.UseAuthentication(); antes de app.UseAuthorization();.
3) “Unknown column” o mapeos raros en Dapper
Síntoma: JSON llega raro o propiedades en null.
Solución: usa alias (user_id AS UserId). Dapper no te hace el puente snake_case → PascalCase por arte de magia.
4) LAST_INSERT_ID() te regresa 0 o truena
Síntoma: insertas pero no obtienes id.
Solución: corre todo en la misma conexión y revisa el query. Si tu host no permite multi-statements, separa el insert y luego consulta el id.
5) Deploy con “me funciona local” pero en el server no conecta a MySQL
Síntoma: 500 y logs con “cannot connect”.
Solución: revisa firewall, credenciales, host/puerto y permisos. En hosting barato es común que el host de MySQL sea uno interno distinto al que te imaginabas.
Checklist final
- Scripts SQL versionados:
001_init.sql,002_seed.sql. - API corre en .NET 8 y Swagger funciona.
- Dapper mapea bien (
AS UserId,AS IsDone). - Login regresa JWT y el frontend lo guarda.
- Endpoints de tasks protegidos con
[Authorize]. - Backend valida
user_iden updates/deletes. - CORS solo permite mi frontend.
- Secretos fuera del código para production.
FAQ
1) ¿Por qué Dapper y no “algo que haga todo solo”?
Porque en APIs chicas/medianas te da control, buen performance y queries explícitas. Cuando algo falla, sabes dónde ver y qué tocar.
2) ¿Dónde guardo el token JWT en el frontend?
Para un MVP: localStorage jala, con riesgos (XSS). Para algo serio, cookies HttpOnly. Lo importante es no venderlo como “súper seguro” si no lo es.
3) ¿Cómo sé qué respuestas debe dar mi API?
Contrato consistente: HTTP codes que digan la verdad + { error: "..." } para errores. Soporte y frontend te lo agradecen.
4) ¿Qué hago si mi API y frontend corren en distintos dominios?
Configura CORS con WithOrigins(...). No dejes AllowAnyOrigin() en production salvo que tengas una razón muy bien amarrada.
5) ¿Cómo versiono cambios de DB sin herramienta fancy?
Scripts numerados en /db/scripts y una regla: no editas el pasado, agregas uno nuevo. Eso te salva cuando alguien clona el repo y necesita levantar el setup en corto.
Siguiente episodio: teaser
Tu frontend ya habla con tu backend… ahora toca que hable con disciplina.
DTOs, validaciones, errores más finos y un workflow que no se rompa en el primer refactor (ni cuando te caiga un pull request con prisa).
Idea para cerrar bien este post: toma una sola práctica de aquí y conviértela en algo que tu equipo pueda aplicar hoy.
Cuando un artículo aterriza en decisiones reales, deja de ser contenido y se vuelve ventaja.


