¡Ven a la JSConf España 2026! Comprar entradas

Navegación a la página de detalle

Introducción

Ya tenemos una página de detalle y funciona correctamente. Podemos volver atrás, podemos volver al inicio…

Pero desde el listado NO podemos entrar al detalle de ninguna oferta.

Cada JobCard muestra la información (título, empresa, ubicación), pero no hay ningún enlace hacia /jobs/:id.

En esta clase vamos a solucionar exactamente eso: hacer que cada tarjeta sea clickeable y lleve a su página de detalle.

El problema actual

Si revisas tu componente JobCard, probablemente se vea algo así:

export function JobCard({ job }) {
  return (
    <article className={styles.card}>
      <h3 className={styles.title}>{job.title}</h3>
      <p className={styles.company}>{job.company}</p>
      <p className={styles.location}>{job.location}</p>
      <div className={styles.tags}>
        {job.tags?.map((tag) => (
          <span key={tag} className={styles.tag}>
            {tag}
          </span>
        ))}
      </div>
    </article>
  )
}

No hay ninguna forma de navegar al detalle. Es solo una tarjeta estática.

La forma más simple es envolver toda la tarjeta en un componente Link:

import { Link } from 'react-router'
import styles from './JobCard.module.css'

export function JobCard({ job }) {
  return (
    <Link to={`/jobs/${job.id}`} className={styles.cardLink}>
      <article className={styles.card}>
        <h3 className={styles.title}>{job.title}</h3>
        <p className={styles.company}>{job.company}</p>
        <p className={styles.location}>{job.location}</p>
        <div className={styles.tags}>
          {job.tags?.map((tag) => (
            <span key={tag} className={styles.tag}>
              {tag}
            </span>
          ))}
        </div>
      </article>
    </Link>
  )
}

Puntos clave

  • Construimos la URL dinámicamente con el id de la oferta
  • Envolvemos el <article> completo: Toda la tarjeta es clickeable
  • className={styles.cardLink}: Para aplicar estilos al enlace

Estilos necesarios

Para que funcione bien visualmente, necesitas algo como:

/* JobCard.module.css */

.cardLink {
  text-decoration: none;
  color: inherit;
  display: block;
  transition: transform 0.2s ease;
}

.cardLink:hover {
  transform: translateY(-4px);
}

.cardLink:hover .card {
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}

.card {
  background: white;
  border-radius: 8px;
  padding: 1.5rem;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  transition: box-shadow 0.2s ease;
}

Ventajas de este enfoque

  • Simple y directo
  • Toda la tarjeta es clickeable: Mejor UX
  • Funciona con teclado: Puedes navegar con Tab y Enter
  • Click derecho funciona: “Abrir en nueva pestaña” funciona correctamente
  • Accesibilidad: Los screen readers entienden que es un enlace

Solución 2: Usar navegación programática con onClick

Otra opción es usar navegación programática con el hook useNavigate:

import { useNavigate } from 'react-router'
import styles from './JobCard.module.css'

export function JobCard({ job }) {
  const navigate = useNavigate()

  const handleClick = () => {
    navigate(`/jobs/${job.id}`)
  }

  return (
    <article className={styles.card} onClick={handleClick} style={{ cursor: 'pointer' }}>
      <h3 className={styles.title}>{job.title}</h3>
      <p className={styles.company}>{job.company}</p>
      <p className={styles.location}>{job.location}</p>
      <div className={styles.tags}>
        {job.tags?.map((tag) => (
          <span key={tag} className={styles.tag}>
            {tag}
          </span>
        ))}
      </div>
    </article>
  )
}

Problemas de este enfoque

  • No funciona con click derecho: No puedes “Abrir en nueva pestaña”
  • No funciona con Ctrl/Cmd + Click: No abre en nueva pestaña
  • No funciona con teclado: No puedes navegar con Tab
  • Peor accesibilidad: Los screen readers no saben que es clickeable
  • Peor SEO: Los crawlers no pueden seguir el enlace

Cuándo usar navegación programática

Usa navigate() cuando:

  • La navegación ocurre después de una acción: Como enviar un formulario
  • Necesitas hacer algo antes de navegar: Como guardar datos
  • Es una consecuencia de otra acción: No es el propósito principal del elemento

No uses navigate() para enlaces simples. Usa <Link>.

Solución 3: Híbrido con botón adicional

A veces quieres que la tarjeta sea clickeable, pero también tener un botón específico:

import { Link } from 'react-router'
import styles from './JobCard.module.css'

export function JobCard({ job }) {
  return (
    <article className={styles.card}>
      <Link to={`/jobs/${job.id}`} className={styles.cardContent}>
        <h3 className={styles.title}>{job.title}</h3>
        <p className={styles.company}>{job.company}</p>
        <p className={styles.location}>{job.location}</p>
        <div className={styles.tags}>
          {job.tags?.map((tag) => (
            <span key={tag} className={styles.tag}>
              {tag}
            </span>
          ))}
        </div>
      </Link>

      <div className={styles.actions}>
        <button
          onClick={(e) => {
            e.stopPropagation()
            // Guardar como favorito
          }}
          className={styles.favoriteButton}
        >
          ⭐ Guardar
        </button>

        <Link to={`/jobs/${job.id}`} className={styles.detailButton}>
          Ver detalles →
        </Link>
      </div>
    </article>
  )
}

Nota importante sobre e.stopPropagation()

Si tienes botones dentro del enlace, necesitas e.stopPropagation() para evitar que el click en el botón también active el enlace.

Mejorando la accesibilidad

Para hacer tu tarjeta más accesible:

<Link
  to={`/jobs/${job.id}`}
  className={styles.cardLink}
  aria-label={`Ver detalles de ${job.title} en ${job.company}`}
>
  {/* contenido de la tarjeta */}
</Link>

Opción 2: Agregar role=“button” y keyboard handlers

Si usas navegación programática (no recomendado, pero por si acaso):

<article
  className={styles.card}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      handleClick()
    }
  }}
  role="button"
  tabIndex={0}
  aria-label={`Ver detalles de ${job.title}`}
>
  {/* contenido */}
</article>

Pero de verdad, usa <Link> en su lugar.

Indicadores visuales de que es clickeable

Cursor pointer

.cardLink {
  cursor: pointer;
}

Hover effects

.cardLink:hover .card {
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

Focus visible para teclado

.cardLink:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 4px;
  border-radius: 8px;
}

Pasando estado en la navegación

A veces quieres pasar información adicional al navegar:

<Link to={`/jobs/${job.id}`} state={{ from: '/jobs', searchQuery: 'react' }}>
  {/* contenido */}
</Link>

Y en la página de detalle puedes acceder a ese estado:

import { useLocation } from 'react-router'

export function JobDetail() {
  const location = useLocation()
  const { from, searchQuery } = location.state || {}

  // Puedes usar esto para mostrar un botón "Volver a búsqueda"
  // O para recordar filtros aplicados
}

Abriendo en nueva pestaña

Si quieres que algunas tarjetas se abran en nueva pestaña:

<Link to={`/jobs/${job.id}`} target="_blank" rel="noopener noreferrer">
  {/* contenido */}
</Link>

Importante: rel=“noopener noreferrer”

  • noopener: Evita que la nueva pestaña pueda acceder a window.opener
  • noreferrer: No envía el header Referer
  • Seguridad: Previene ataques de phishing y protege la privacidad

Componente completo recomendado

Juntando las mejores prácticas:

import { Link } from 'react-router'
import styles from './JobCard.module.css'

export function JobCard({ job }) {
  return (
    <Link
      to={`/jobs/${job.id}`}
      className={styles.cardLink}
      aria-label={`Ver detalles de ${job.title} en ${job.company}`}
    >
      <article className={styles.card}>
        <div className={styles.header}>
          <h3 className={styles.title}>{job.title}</h3>
          {job.isNew && <span className={styles.badge}>Nuevo</span>}
        </div>

        <div className={styles.meta}>
          <p className={styles.company}>
            <span className={styles.icon}>🏢</span>
            {job.company}
          </p>
          <p className={styles.location}>
            <span className={styles.icon}>📍</span>
            {job.location}
          </p>
        </div>

        {job.tags && job.tags.length > 0 && (
          <div className={styles.tags}>
            {job.tags.map((tag) => (
              <span key={tag} className={styles.tag}>
                {tag}
              </span>
            ))}
          </div>
        )}

        <div className={styles.footer}>
          <span className={styles.salary}>{job.salary || 'Salario a convenir'}</span>
          <span className={styles.viewMore}>Ver más →</span>
        </div>
      </article>
    </Link>
  )
}

CSS recomendado

/* JobCard.module.css */

.cardLink {
  text-decoration: none;
  color: inherit;
  display: block;
}

.card {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
  padding: 1.5rem;
  transition: all 0.2s ease;
}

.cardLink:hover .card {
  border-color: #0066cc;
  box-shadow: 0 8px 16px rgba(0, 102, 204, 0.1);
  transform: translateY(-2px);
}

.cardLink:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 4px;
  border-radius: 12px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: start;
  margin-bottom: 0.75rem;
}

.title {
  font-size: 1.25rem;
  font-weight: 600;
  color: #111827;
  margin: 0;
}

.badge {
  background: #10b981;
  color: white;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 600;
}

.meta {
  display: flex;
  gap: 1rem;
  margin-bottom: 1rem;
  color: #6b7280;
  font-size: 0.875rem;
}

.meta p {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

.tag {
  background: #f3f4f6;
  color: #374151;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 500;
}

.footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 1rem;
  border-top: 1px solid #e5e7eb;
}

.salary {
  font-weight: 600;
  color: #0066cc;
}

.viewMore {
  color: #6b7280;
  font-size: 0.875rem;
  transition: color 0.2s;
}

.cardLink:hover .viewMore {
  color: #0066cc;
}

Resumen

En esta clase hemos aprendido:

  • Cómo hacer clickeable cada tarjeta del listado usando el componente Link
  • Por qué usar <Link> es mejor que navigate() para enlaces simples
  • Cómo construir URLs dinámicas con template literals: `/jobs/${job.id}`
  • Mejores prácticas de accesibilidad con aria-label y focus-visible
  • Indicadores visuales de que algo es clickeable: hover, cursor, transiciones
  • Cómo pasar estado en la navegación si lo necesitas
  • Componente completo con todas las mejores prácticas aplicadas

Ahora tu listado de ofertas está completamente conectado con las páginas de detalle. Los usuarios pueden:

  • Hacer click en cualquier parte de la tarjeta para ver más
  • Usar Tab para navegar por teclado
  • Hacer click derecho para abrir en nueva pestaña
  • Usar Ctrl/Cmd + Click para abrir en background

Una experiencia de usuario completa y profesional.