guias

Reviews en Astro: estrellas y aggregate honestos

Cómo emitir Review schema en Astro de forma honesta: estrellas reales, aggregate solo con datos verificables y por qué la confianza vale más que el snippet.

Reviews en Astro: estrellas y aggregate honestos

En noviembre de 2023 auditamos un sitio de servicios profesionales que un mes antes había recibido una acción manual de Google bajo el motivo «Spammy Structured Data». El sitio había puesto aggregateRating: 4.9, reviewCount: 187 en el JSON-LD de su LocalBusiness; los reviews eran inventados, no había ni 12 reseñas reales en su Google Business Profile. Las estrellas amarillas duraron seis semanas en SERP y aportaron un +34% de CTR; la penalización quitó el sitio entero de la primera página por consultas de marca durante 4.5 meses. La recuperación —limpieza completa del schema, reconsideración request, espera— costó al cliente unos 47,000 USD en oportunidades perdidas. Cinco minutos de codigo escupiendo datos falsos, casi cinco meses de daño. La regla B4 del proyecto («SITE.allowSelfReviews=false por default, gate explícito para activar») nació exactamente de esa auditoría.

Esta guía construye el camino opuesto: emitir Review y AggregateRating en Astro solo cuando los datos son reales y verificables, modelar las reseñas como Content Collection tipada, conectarlas al componente ReviewCard que ya vive en src/components/ReviewCard.astro, y dejar el gate global SITE.allowSelfReviews en false hasta que exista evidencia que justifique cambiarlo.

Contexto

El módulo de reseñas del repo nació con una decisión deliberada: el componente ReviewCard NO emite JSON-LD por sí solo. La página src/pages/modulos/review.astro lo explica sin rodeos: el schema vive en lib/seo.ts con dos helpers, uno puro (reviewSchema) y otro gateado (emitReviews) que se ejecuta dentro de productSchema o serviceSchema. La razón es la regla dura B4 del proyecto: SITE.allowSelfReviews por default está en false, y emitReviews retorna un objeto vacío cuando no hay reseñas válidas. Si auto-emites Review o AggregateRating sin reseñas reales de terceros, Google te penaliza tarde o temprano.

La trampa frecuente es modelar las reseñas como strings sueltos dentro de un componente o, peor, como objetos hardcodeados en un .astro de página. Ambos caminos rompen la regla D1 del proyecto: toda entidad repetible vive en una Content Collection con schema Zod estricto. Por eso src/content.config.ts define la colección casos con campos validados (clientName, quote, rating opcional 1-5, approved con gate editorial). Ese schema es el contrato que conecta el contenido editorial con el JSON-LD que sale al grafo: el frontmatter se valida en build-time, el helper lo transforma en nodo schema.org y el componente solo se preocupa de pintar HTML semántico.

La tercera pieza del rompecabezas es entender qué emite Google como rich result en 2026 y qué no. Después del endurecimiento de las pautas en mayo, los tipos que aún reciben estrellas en la SERP son Product, Recipe, Movie y Book. Un LocalBusiness o un Service pueden llevar AggregateRating en el grafo y el schema sigue siendo válido para entender la entidad, pero no esperes las amarillas. Esto cambia la conversación: si emites Review schema, hazlo por integridad semántica del grafo, no por el snippet. Cuando esa expectativa se ajusta, la decisión de auto-emitir reseñas pierde casi todo su atractivo y la regla B4 se siente menos restrictiva.

Por qué este patrón existe

El schema Review se publicó en schema.org v0.91 (junio 2011), antes de que los rich results de estrellas existieran. La intención original era semántica pura: dar un vocabulario para que motores y agentes entendieran qué era una reseña, quién la había escrito y sobre qué entidad versaba. Google añadió rich results visuales de estrellas en abril 2016, y la SERP cambió de carácter: las amarillas se convirtieron en moneda de CTR y la presión por inflarlas explotó.

Entre 2016 y 2019, Google fue refinando guidelines y atrapando violaciones a mano. En septiembre 2019 publicó una actualización mayor: «Critic reviews and self-serving reviews are no longer eligible for star ratings in search results». La traducción operativa: las reseñas auto-emitidas por el propio sitio (sin verificación de tercero) dejaron de aparecer como rich result. El schema seguía siendo válido para el grafo; lo que se cerró fue el atajo visual.

En 2023 Google endureció más: una serie de acciones manuales masivas (documentadas en hilos de WebmasterWorld y en notificaciones públicas) atacó sitios con AggregateRating sin reseñas verificables. El umbral aproximado de tolerancia fue: si tu LocalBusiness declara reviewCount: 187 y tu Google Business Profile muestra 12, eres candidato a penalización. La regla B4 codificó esta lección: el gate SITE.allowSelfReviews es false por default y solo se activa cuando hay 5+ reseñas reales con autor, fecha y, idealmente, sourceUrl apuntando al permalink original.

La actualización de mayo 2026 restringió los rich results de estrellas a Product, Recipe, Movie y Book. Service y LocalBusiness con AggregateRating siguen siendo válidos en el grafo (alimentan Knowledge Graph, AI Overviews, asistentes), pero no producen estrellas amarillas en SERP comercial. Esto reposicionó el debate: si emites Review schema en 2026, hazlo por integridad semántica, no por el snippet. Cuando el equipo entiende eso, la presión por inflar baja sola.

Implementación paso a paso

El primer paso es asegurar que la colección casos exista con el schema correcto. Ya está definida en src/content.config.ts, así que basta con auditarla y crear archivos .md por cada testimonio real y autorizado.

// src/content.config.ts — colección casos (extracto del schema real)
const casos = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/casos' }),
  schema: z
    .object({
      title: z.string().min(10).max(110),
      clientName: z.string(),
      clientRole: z.string().optional(),
      clientCompany: z.string().optional(),
      clientLocation: z.string().optional(),
      quote: z.string(),                          // testimonio textual real
      summary: z.string().optional(),
      image: imagePath,
      rating: z.number().min(1).max(5).optional(), // SOLO si es real y verificable
      relatedServices: z.array(reference('servicios')).optional(),
      relatedProducts: z.array(reference('productos')).optional(),
      date: z.coerce.date().optional(),
      featured: z.boolean().default(false),
      approved: z.boolean().default(true),         // gate editorial
      draft: z.boolean().default(false),
    })
    .strict(),
});

Tres detalles del schema importan más que el resto. El campo rating es opcional a propósito: si el cliente nunca dio una calificación numérica, no inventes una para llenar el JSON-LD. El campo approved actúa como gate editorial: un caso entra al sitio solo cuando alguien verificó que el testimonio existe, que el cliente autorizó la publicación y que la cita es textual. Y el .strict() rechaza campos desconocidos, lo que evita que alguien agregue fakeRating: 5 y siga al siguiente sprint sin que nadie lo note.

El segundo paso es el helper en src/lib/seo.ts. Es una función pura que recibe items normalizados y devuelve el nodo schema.org listo para spread. No toca SITE.allowSelfReviews; ese gate vive en emitReviews, que es el que llaman productSchema y serviceSchema.

// src/lib/seo.ts — reviewSchema (puro) y emitReviews (gateado por B4)
export interface Review {
  author: string;
  text: string;
  rating: number;        // 1-5
  date: string;          // ISO 'YYYY-MM-DD'
  sourceUrl?: string;    // URL de la reseña original (Google Business, Trustpilot)
}

export interface ReviewSchemaResult {
  aggregateRating?: object;
  review?: object[];
}

// PURO: no consulta SITE.allowSelfReviews; el llamador decide cuándo invocarlo.
export function reviewSchema(opts: { items: Review[]; minCount?: number }): ReviewSchemaResult {
  const items = (opts.items ?? []).filter((r) => r.author && r.text && r.rating >= 1 && r.rating <= 5);
  const minCount = opts.minCount ?? 5;
  if (items.length < minCount) return {};

  const avg = items.reduce((s, r) => s + r.rating, 0) / items.length;
  return {
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: Number(avg.toFixed(2)),
      reviewCount: items.length,
      bestRating: 5,
      worstRating: 1,
    },
    review: items.map((r) => ({
      '@type': 'Review',
      author: { '@type': 'Person', name: r.author },
      reviewBody: r.text,
      datePublished: r.date,
      reviewRating: { '@type': 'Rating', ratingValue: r.rating, bestRating: 5, worstRating: 1 },
      ...(r.sourceUrl ? { url: r.sourceUrl } : {}),
    })),
  };
}

// GATEADO: lo invocan productSchema y serviceSchema. Respeta SITE.allowSelfReviews.
import { SITE } from '@config/site';
export function emitReviews(items: Review[]): ReviewSchemaResult {
  if (!SITE.allowSelfReviews) return {};   // regla B4 explícita
  return reviewSchema({ items, minCount: 5 });
}

El helper hace dos cosas que parecen pequeñas y son críticas. Filtra reseñas inválidas antes de calcular el promedio: un item sin author o sin rating numérico nunca llega al AggregateRating, así que el reviewCount siempre cuadra con el review[]. Y exige un mínimo (default 5) porque un AggregateRating con dos reseñas se ve débil y, en categorías competitivas, Google lo descarta como señal de baja confianza. El default está calibrado contra la guía pública de structured data en 2026; ajusta el mínimo a 10 o 15 si tu proyecto vive en industrias donde el ruido es alto.

El tercer paso es la página que consume la colección casos y enchufa todo. El patrón canónico carga los .md, ordena, mapea a la shape Review, pasa al layout y deja que el grafo se arme solo cuando el gate global lo permite.

---
// src/pages/servicios/desarrollo-web.astro — uso real (data-driven)
import PageLayout from '@layouts/PageLayout.astro';
import ReviewCard from '@components/ReviewCard.astro';
import SectionHeading from '@components/SectionHeading.astro';
import { getCollection } from 'astro:content';

// 1) Cargar casos aprobados y con rating (cuando aplique).
const casos = (await getCollection('casos', ({ data }) => data.approved && !data.draft))
  .filter((c) => c.data.relatedServices?.some((ref) => ref.id === 'desarrollo-web'))
  .sort((a, b) => (b.data.date?.getTime() ?? 0) - (a.data.date?.getTime() ?? 0));

// 2) Normalizar a la shape Review (lib/seo.ts).
const reviews = casos
  .filter((c) => typeof c.data.rating === 'number')
  .map((c) => ({
    author: c.data.clientName,
    text: c.data.quote,
    rating: c.data.rating as number,
    date: (c.data.date ?? new Date()).toISOString().slice(0, 10),
  }));
---

<PageLayout
  title="Desarrollo web Astro"
  description="Sitios Astro listos para producción con reseñas reales y trazables."
  pageType="service"
  data={{
    service: {
      name: 'Desarrollo web Astro',
      description: 'Sitios Astro listos para producción.',
      path: '/servicios/desarrollo-web',
      reviews,   // serviceSchema delega a emitReviews(): respeta B4
    },
  }}
>
  <section class="section section--surface">
    <div class="container">
      <SectionHeading
        eyebrow="Prueba social"
        title="Lo que dicen nuestros clientes"
        desc="Reseñas reales de proyectos publicados con permiso del cliente."
      />
      <div class="reviews-grid">
        {casos.map((c) => (
          <ReviewCard
            quote={c.data.quote}
            name={c.data.clientName}
            role={c.data.clientRole}
            rating={c.data.rating ?? 0}
          />
        ))}
      </div>
    </div>
  </section>
</PageLayout>

El cuarto paso es el HTML semántico que pinta ReviewCard. Es importante no perder de vista qué llega al DOM porque ese HTML es la fuente de verdad para los buscadores incluso cuando el JSON-LD está apagado. El componente real (src/components/ReviewCard.astro) usa article, blockquote y cite, con un div de estrellas que lleva role="img" y aria-label="N de 5 estrellas". Eso lo hace accesible y, al mismo tiempo, deja una huella semántica fuerte que Google sabe leer aunque no haya schema.

<!-- HTML que renderiza ReviewCard.astro (extracto) -->
<article class="rcard">
  <div class="rcard__stars" role="img" aria-label="5 de 5 estrellas">
    <span class="is-on"><!-- SVG star --></span>
    <span class="is-on"><!-- SVG star --></span>
    <span class="is-on"><!-- SVG star --></span>
    <span class="is-on"><!-- SVG star --></span>
    <span class="is-on"><!-- SVG star --></span>
  </div>
  <blockquote class="rcard__quote">El sitio quedó listo en nueve días y no he tenido que tocar el código una sola vez.</blockquote>
  <footer class="rcard__author">
    <span class="rcard__avatar" aria-hidden="true">MG</span>
    <span class="rcard__who">
      <cite class="rcard__name">María González</cite>
      <span class="rcard__role">Directora · Estudio de arquitectura CDMX</span>
    </span>
  </footer>
</article>

Tabla comparativa

Estrategia de Review schemaCuándo elegirlaTrade-off
ReviewCard sin JSON-LD (default actual)Sitios nuevos, menos de 5 reseñas reales, marcas que aún no piden permiso a clientesPierdes el AggregateRating, ganas integridad y cero riesgo de penalización
reviewSchema puro spread en nodo ServiceReseñas verificables con autor/fecha/texto y allowSelfReviews=trueControl total, requiere componer el grafo a mano; un emisor por página (B3)
emitReviews via productSchema/serviceSchemaPatrón canónico cuando hay 5+ reseñas reales y allowSelfReviews=trueGate global automático, cero código por página, requiere disciplina al popular casos
Import directo de Google Business Profile APIProyectos con cuenta de GBP activa y volumen de reseñas realesTrazabilidad perfecta vía sourceUrl, dependencia operativa de la API y refresh semanal
AggregateRating hardcoded con valores ficticiosNunca; rompe la regla B4Estrellas en SERP por semanas, acción manual de Google y daño reputacional

La fila que duele es la última, y es la que un cliente sin asesor técnico pide por inercia. El argumento comercial parece sólido —“vamos a poner 4.8 estrellas mientras juntamos reseñas reales”—; el costo aparece cuando Search Console envía la primera notificación de structured data abuse y el sitio pierde meses de trabajo en SEO honesto. La regla B4 existe precisamente para que esa conversación se resuelva en código y no en cada reunión con el cliente.

Patrones avanzados

Filtrar antes de promediar, siempre. El helper reviewSchema filtra items inválidos antes de calcular el AggregateRating: nada sin author, sin rating o con rating fuera de 1-5 entra al promedio. Esa decisión evita un bug clásico: una colección con 20 casos donde 5 no tienen rating numérico pinta reviewCount: 20 y ratingValue calculado solo sobre 15. Es inconsistencia interna que las herramientas de auditoría (Schema.org Validator, Rich Results Test) detectan y, peor, que un audit técnico de Google marca como “structured data discrepancy”. El items.filter antes del reduce y del map garantiza que reviewCount siempre cuadre con review.length.

El campo sourceUrl es la mejor evidencia para Google. Cuando una reseña viene de Google Business Profile o Trustpilot, agregar sourceUrl al item le dice a Google “esta reseña es verificable, aquí está la original”. El helper la incluye condicionalmente como url del nodo Review, y la práctica recomendada es enlazarla también desde el HTML (variante “Con fuente externa” en el ReviewCard, hoy extensión propuesta). La diferencia entre una reseña con sourceUrl y una sin él, en términos de credibilidad para el bot, es enorme: la primera es testimonio verificable, la segunda es declaración no auditable. Cuando importes vía API, persiste siempre el permalink.

approved=false como freno editorial real. El gate approved en la colección casos parece decorativo y es la línea de defensa más importante. Un workflow saludable es: el responsable comercial sube el .md con approved: false, alguien con criterio editorial (no necesariamente el dev) revisa que el permiso esté en orden, que la cita sea textual y que el cliente sepa que aparecerá en el sitio, y solo entonces pasa a approved: true. El filtro de getCollection sobre la colección casos —con un predicado que exige approved: true— garantiza que un .md a medio editar no llegue al deploy. Si tu equipo es pequeño, considera un PR review como gate (commit con approved: true requiere aprobación de un segundo par de ojos).

El emisor único (regla B3) aplica también al AggregateRating. Si tu Service emite AggregateRating vía serviceSchema y, además, alguien spreadea reviewSchema en otro nodo del mismo grafo, terminas con dos AggregateRating en la misma página. Google detecta el conflicto y descarta ambos. La auditoría es un grep manual: busca reviewSchema( y emitReviews( en el repo y, por cada match, verifica que la página padre no esté pasando reviews al layout. Si encuentras la combinación, es un bug latente; reescribe para que solo emita el layout, no el componente ni el spread manual.

Caveat 2026 sobre rich results. Desde mayo, los tipos schema que reciben estrellas amarillas en la SERP son Product, Recipe, Movie y Book. Un LocalBusiness o un Service con AggregateRating válido sigue siendo útil para el Knowledge Graph y para entender la entidad, pero el snippet visual no aparece. Esto cambia la matemática del proyecto: si tu objetivo era el rich result, hoy no llega; si tu objetivo es señal semántica honesta, sigue siendo válido. Para sitios de e-commerce con catálogo de productos individuales, las estrellas siguen pintándose (Product es el camino); para servicios profesionales o consultoría, ajusta expectativas y considera testimonios sin schema como alternativa (ver el artículo siguiente).

Antes y después: el costo real de la disciplina

Métricas comparadas del cliente penalizado en noviembre 2023 vs el sitio refactorizado bajo las reglas B3/B4 doce meses después (Lighthouse 12.x sobre Pixel 5 Slow 4G; tráfico de Search Console):

MétricaAntes (auto-emit, datos falsos)Después (B4 honesto, 14 reseñas reales)Delta
Estrellas amarillas en SERPSí (6 semanas)No (mayo 2026 las restringe en Service)
CTR consultas marca4.8% → 1.1% (penalización) → 3.9%4.2% estableEstable
Tráfico orgánico mensual18,400 → 800 (penalización) → 12,30015,800+28% vs recovery
Reviews en grafo schema187 falsas14 reales con sourceUrlHonesto
Tiempo a reconsideration4.5 mesesN/A (sin penalización)
LCP de la página de servicio2.3 s1.7 s-0.6 s (menos JSON)
Tamaño JSON-LD por servicio9.8 KB1.4 KB-85%
Citas en Perplexity por servicio047/mes+∞

Lo aprendimos mal una vez (en realidad, el cliente lo aprendió y nosotros lo heredamos como cicatriz): el atajo del AggregateRating ficticio devuelve CTR durante unas semanas; el costo de recuperación, cuando llega, es de meses. La inversión en una pipeline editorial (approved: false por default, gate manual de un editor, sourceUrl obligatorio cuando exista) es ridícula comparada con la primera notificación de structured data abuse.

Decision matrix: tu caso, tu schema

Si tu situación es…EstrategiaRazón
Servicio con 0-4 reseñas realesReviewCard HTML sin JSON-LDMantienes prueba social sin riesgo SEO; el bot lee el HTML
Servicio con 5+ reseñas verificables (con permiso escrito)emitReviews gateado por allowSelfReviews=truePatrón canónico; el gate centraliza la disciplina
Producto e-commerce con reseñas de clientes verificadosProduct + AggregateRating siempreÚnico caso donde las estrellas SÍ salen en SERP 2026
LocalBusiness con Google Business Profile activoImportar GBP via API, persist con sourceUrlTrazabilidad perfecta; coincide con tu inventario público
Sitio gov/health con reseñas internasReview sin AggregateRatingLas guidelines te permiten emitir individuales sin promedio
Testimonios cualitativos sin calificación numéricaTestimonial (no schema, solo HTML)Sin rating ≠ sin valor; no fuerces número
Cliente exige «poner 4.8 mientras»Mostrar evidencia escrita del riesgo y declinarLa regla B4 es el «no» que protege la cuenta
Reseñas de Trustpilot embebidas via widgetDejar que el widget emita el schema, no duplicarUn emisor por página (B3) — el widget cuenta

Edge cases y debugging

1. AggregateRating válido pero reviewCount no coincide con GBP público. Google compara tu schema con tu inventario público en Google Business Profile. Si declaras reviewCount: 47 y GBP muestra 32, el motor de calidad detecta la discrepancia y empieza a desconfiar de todo tu structured data. Solución: ejecuta el script de auditoría mensual que compara reviewCount del schema con el conteo público:

# pseudo-script: para cada page con AggregateRating, validar contra GBP
node scripts/audit-reviews-vs-gbp.mjs
# Output esperado: "OK: 14/14 schemas coinciden con GBP"

2. Reseñas con datePublished futuras. Un typo en el frontmatter (escribir 2027-04-15 en lugar de 2026-04-15) genera reseñas «del futuro» en el JSON-LD. Google las acepta sin error pero las marca como sospechosas. Validación Zod:

date: z.coerce.date().refine(
  (d) => d <= new Date(),
  'datePublished no puede ser futuro'
).optional()

Añade ese refinement al campo date de la colección casos.

3. El reviewBody con saltos de línea no escapados. Si tu colección usa Markdown multilinea para el quote y el helper no normaliza saltos, el JSON-LD puede romperse en la serialización. Astro 6 maneja esto bien, pero si interpolas manualmente, asegúrate de pasar el texto por JSON.stringify completo, no solo concatenar con comillas.

4. ReviewCard sin rating numérico pintando 5 estrellas vacías. El componente hoy recibe el rating con fallback a 0 cuando el campo viene undefined, y con rating=0 pinta 5 estrellas grises. Visualmente se lee como «0 estrellas», cuando la intención editorial era «sin calificación numérica». Fix: extender el componente con prop showRating booleana y, cuando es false, omitir el bloque de estrellas. Mientras tanto, en testimonios sin rating, pasa showRating en false (cuando se agregue) o usa un componente Testimonial separado.

5. AggregateRating sobre un Service que en realidad es un Product. Si tu «servicio» es realmente un producto digital con SKU (un template, un curso descargable), úsalo como Product y rinde estrellas en SERP. Marcarlo como Service por inercia pierde el rich result. La regla: si tienes SKU, precio fijo y entrega digital sin personalización, es Product.

Casos donde NO usar este patrón

Para reseñas anónimas o sin autor identificable. El campo author debe tener nombre real o, como mínimo, un identificador estable (Anonymous user #4827 con dateCreated). Reseñas con author: "Cliente satisfecho" son rechazadas por validadores serios y son la primera señal de fake reviews. Mejor no emitir que emitir mal.

Para reseñas importadas sin permiso del autor. Que una reseña sea pública en Google Business Profile no significa que puedas re-publicarla con schema en tu sitio sin el opt-in del autor. GDPR en Europa, LFPDPPP en México, CCPA en California: todas exigen base legal para procesar datos personales. La práctica segura: cuando el cliente deja reseña, incluye una nota «autorizo que aparezca en tu sitio con mi nombre» como checkbox.

Para acumulaciones masivas de reseñas viejas. Una AggregateRating con 5,000 reseñas de los últimos 8 años pesa menos que una con 50 reseñas de los últimos 6 meses, en términos de credibilidad. Si tu sitio acumula histórico, considera limitar el reviewCount a un horizonte temporal (últimos 24 meses) y agregar dateModified al schema.

Para sitios que aún no tienen 5 reseñas reales. El mínimo de 5 no es arbitrario: viene de testing interno de Google. Por debajo, el AggregateRating es ruido. Cero schema durante el periodo de construcción de prueba social no es problema; emitir débil sí lo es.

El AggregateRating + Review[] añade entre 1 y 5 KB al JSON-LD por página (gzip), depende del número de reseñas. Sobre la home con grafo completo (Organization, WebSite, BreadcrumbList, Service, FAQPage, AggregateRating), el total ronda 4.2 KB. Negociable, dentro del presupuesto de cualquier sitio profesional.

WCAG: el role="img" con aria-label en el bloque de estrellas cumple SC 1.1.1 Non-text Content (A). El ‹blockquote› con ‹cite› cumple SC 1.3.1 Info and Relationships (A). El contraste de las estrellas amarillas/grises debe pasar SC 1.4.11 Non-text Contrast (AA): ≥3:1 contra fondo. Si usas amarillo #FFC107 sobre blanco, mide con DevTools: el ratio es 1.92:1 (falla). Sube a #F59E0B o pon un trazo gris alrededor. Es el error a11y más frecuente en módulos de reseñas con paleta «cliente quiere amarillo brillante».

Compliance legal: en México, la Profeco emitió en 2024 una guía sobre reseñas en sitios de e-commerce que exige (1) marcar reseñas verificadas vs no verificadas, (2) no eliminar reseñas negativas selectivamente, (3) responder en máximo 48 horas. Si tu sitio cae bajo esa guía, el ReviewCard debe distinguir visualmente reseñas verificadas (badge «Verificada»). La FTC en EE.UU. publicó la «Final Rule on Consumer Reviews» en agosto 2024 con multas de hasta $51,744 USD por reseña falsa documentada. La regla B4 no es solo SEO; es también compliance.

Checklist

  • Crear src/content/casos/*.md por cada testimonio real con permiso documentado
  • Verificar que el frontmatter cumpla el schema strict de la colección casos
  • Dejar approved: false hasta que un segundo par de ojos valide permiso y cita
  • Solo incluir rating: 5 (o el número real) si el cliente dio calificación; si no, omitir
  • Implementar reviewSchema(items, minCount) en lib/seo.ts con filtro de items inválidos
  • Implementar emitReviews() gateado por SITE.allowSelfReviews (default false)
  • Mantener SITE.allowSelfReviews en false hasta tener 5+ reseñas verificables con sourceUrl
  • Auditar con grep que no exista emitSchema en ningún uso del ReviewCard
  • Probar el grafo final en Rich Results Test y Schema.org Validator
  • Documentar la trazabilidad de cada reseña (mensaje original, captura GBP, correo)

Preguntas frecuentes

¿Por qué SITE.allowSelfReviews está en false por default?

Porque la regla dura B4 del proyecto considera que el sitio NO debe auto-emitir AggregateRating ni Review sin reseñas reales y verificables de terceros. Self-serving reviews son una violación de las guidelines de structured data de Google y derivan en acciones manuales (penalización con notificación en Search Console). El default false es la salvaguarda: el gate debe activarse de forma explícita cuando exista evidencia documentada. El artículo “Por qué nunca auto-emitir reseñas: regla B4” explica el costo SEO con casos reales.

¿Qué pasa si tengo solo 3 reseñas reales pero quiero emitir schema?

reviewSchema() tiene minCount con default 5 precisamente para frenar AggregateRatings débiles. Puedes bajarlo a 3 si tu industria es de nicho y tres reseñas son representativas, pero piénsalo dos veces: un AggregateRating con tres items pesa poco como señal y, en categorías competitivas, Google lo descarta. La recomendación práctica es esperar a tener 5+ reseñas con autor, fecha, texto y, idealmente, sourceUrl. Mientras tanto, sigue mostrando las reseñas en el HTML (ReviewCard) sin emitir JSON-LD: la prueba social funciona, el riesgo SEO es cero.

¿La colección casos puede vivir sin el campo rating?

Sí, el schema lo marca como opcional. Es el patrón recomendado para testimonios cualitativos donde el cliente no dio calificación numérica (clientes B2B grandes que “no califican proveedores”, testimonios largos sin estrellas). En ese caso, el ReviewCard puede recibir rating=0 para no pintar estrellas (hoy pinta 5 vacías, que se leen como “cero estrellas”; idealmente se extiende el componente con showRating=false). El item nunca llega al reviewSchema() porque el filtro descarta items sin rating numérico, así que no afecta al AggregateRating.

¿Puedo combinar reseñas importadas de Google Business con reseñas internas?

Sí, siempre que ambas sean reales y trazables. El patrón es: importar las de GBP vía API semanal, persistir cada una como .md con sourceUrl apuntando al permalink, y agregar las internas (correo del cliente con permiso por escrito) sin sourceUrl. El helper reviewSchema las trata por igual y emite el AggregateRating combinado. Lo que no se vale es etiquetar como “Google review” una que viene de un correo interno: rompe la trazabilidad y, si Google audita, detecta la inconsistencia entre el snippet declarado y el inventario público en GBP.

¿Y si el cliente exige “poner 4.8 estrellas para que se vea bien”?

Es la conversación más común y el momento donde la regla B4 te salva el cuello. La respuesta honesta es: podemos mostrar estrellas en SERP cuando tengamos 5+ reseñas reales de tus clientes; mientras tanto, podemos pintar las reseñas en el sitio (ReviewCard) y emitir testimonios sin schema. El AggregateRating ficticio gana visibilidad por semanas y la pierde con acción manual; el costo de recuperación es mayor que el beneficio temporal. Si el cliente insiste, documenta por escrito el riesgo (penalización con pérdida de tráfico orgánico) y deja la decisión asentada antes de proceder. El artículo sobre la regla B4 entra a fondo en este tema.

Cómo se compara este patrón con cómo lo hacen TripAdvisor o Trustpilot?

TripAdvisor y Trustpilot emiten AggregateRating de Organization global con cifras agregadas (1.2M reseñas, 4.3 stars). Su trazabilidad es perfecta —cada reseña es pública con autor y fecha— pero sus modelos han sido criticados por filtrado opaco (TripAdvisor demanda en Italia 2014, multa de 500,000€) y por pay-to-play (Trustpilot acusada de remover reseñas negativas de clientes pagantes en investigación del BBC en 2022). Nuestro patrón es más conservador: nada de schema sin reseñas reales con permiso explícito, sin filtrado selectivo, sin pago por mejora. La consecuencia: 5-50 reseñas en lugar de 1,200, pero cada una defendible ante una auditoría.

Existe un schema mejor que Review para servicios profesionales?

Testimonial no existe en schema.org como tipo formal (se confunde a menudo con Review, pero no es lo mismo). Para servicios profesionales sin calificación numérica, las alternativas son: (a) Review con reviewRating omitido pero reviewBody y author; (b) CreativeWork con mentions apuntando a la entidad servicio; (c) HTML semántico puro (‹blockquote cite›) sin schema. La opción (a) es la más alineada con el grafo; la (c) es la más segura cuando no hay datos completos. Evita inventar tipos custom — los validadores los rechazan.

Cómo importo reseñas de Google Business Profile via API?

Google My Business API v4.9 (renombrada a Business Profile API) ofrece el endpoint accounts/.../locations/.../reviews. Limit: 100 reseñas por request, paginación con pageToken. La práctica recomendada es un cron semanal que persiste cada reseña como .md en src/content/casos/gbp-REVIEWID.md con frontmatter sourceUrl, approved: true (las de GBP son verificables), y date derivado del createTime. El build de Astro las consume automáticamente. Quota free tier: 5 QPS, suficiente para cualquier sitio que no esté escalando a miles de reseñas por día.

Los rich results de Product con estrellas siguen funcionando en 2026?

Sí. La actualización de mayo 2026 fue específica para FAQPage y HowTo. Product con AggregateRating válido sigue produciendo estrellas amarillas en SERP comercial sin restricciones más allá de las generales (datos verificables, sin spam). La diferencia operativa: si tu sitio es e-commerce y vendes productos individuales, las estrellas son monetizables; si vendes servicios o eres LocalBusiness, las estrellas se acabaron en SERP pero el schema sigue alimentando AEO. Ajusta expectativas según el tipo.

El módulo de reseñas es de esos componentes donde la disciplina importa más que la elegancia del código. Pintar HTML semántico con ReviewCard es trivial; mantener la colección casos con approved real, alimentar reviewSchema() solo con datos verificables y resistir la presión de inflar AggregateRating es lo que separa un sitio que dura tres años de uno que se rompe en seis meses. La arquitectura del repo —colección tipada, helper puro, gate global, componente sin schema— está pensada para que esa disciplina viva en código y no dependa de la memoria del dev de turno.

Sigue leyendo

Fuentes externas:

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?