React Email11 min read

Schema.org Email Markup in React Email: Gmail Actions & Order Cards

Add schema.org JSON-LD to React Email templates so Gmail and Outlook show order numbers, tracking, and one-click actions right in the inbox.

R

React Emails Pro

June 4, 2026

Two order confirmation emails land in the same Gmail inbox. One shows up as a plain subject line you have to open and scan. The other shows an order number, a delivery date, and a Track package button rendered directly in the message list, before the user clicks anything. Same email body, same React components. The difference is a small block of application/ld+json that most teams never add.

Schema.org email markup is structured data you embed in the email itself. Gmail reads it (and Outlook increasingly does too) to pull the facts that matter out of a transactional message: the order number, the tracking status, the amount due, a one-click action. The spec has been around for years. Almost nobody uses it, mostly because the setup looks fussier than it actually is, and that is exactly the opening. This post walks through adding it to a React Email template: the types, the one component that does the real work, and the authentication rules that decide whether Gmail trusts you enough to render any of it.

What you will build

A type-safe <EmailSchema /> component that injects valid JSON-LD into any React Email template, plus three concrete payloads: a go-to action button, a Gmail order card, and a parcel-tracking highlight for shipping emails.


What markup actually changes in the inbox

Before writing any code, it helps to know what each schema type actually buys you. Gmail renders a different surface depending on the @type you declare. Worth saying upfront: none of this touches your email design. It only decorates the parts of the inbox your HTML can never reach.

Schema @typeBest forWhat the inbox shows
EmailMessage + ViewActionAny single-link CTAA button next to the subject line (Track, View, Confirm)
OrderOrder confirmation, receiptsAn order card: number, status, items, total
ParcelDeliveryShipping and delivery emailsTracking number, carrier, and expected arrival date
InvoiceBilling and dunning emailsAmount due, due date, and a pay action
FlightReservation, LodgingReservationTravel and booking appsA reservation card plus a Google Calendar entry

Common transactional schema types and where Gmail surfaces them.

Markup is additive and degrades cleanly. Clients that do not understand it ignore the script block entirely. Your normal HTML email renders everywhere exactly as before, so there is no downside risk to the body.

The walkthrough

We will start with the helper that makes everything else safe, then layer on the three payloads, then handle the part everyone skips: getting Gmail to actually trust and render it.

1

Define typed schema payloads

Hand-writing JSON-LD is where most implementations break. A single wrong property name and Gmail silently drops the whole block, with no error anywhere. Lock the shape down with TypeScript so a typo is a build failure, not a production no-op.

2

Build a reusable EmailSchema component

One component serializes any payload into a script tag and renders it inside the email head. Every template imports the same component, so the markup is consistent and you never hand-concatenate JSON again.

3

Add a go-to action to a confirmation email

The lowest-effort, highest-value win: a single action button that appears beside the subject line in Gmail.

4

Mark up the order so Gmail shows a card

Promote an order confirmation from plain text to a structured order summary the inbox can render on its own.

5

Add parcel tracking to shipping emails

Surface the carrier, tracking number, and arrival date so customers stop emailing support to ask where their package is.

6

Authenticate and register the sender

Markup only renders for senders Gmail trusts. This is the gate that decides whether any of the above is visible.


Step 1: Typed schema payloads

You have two options for typing. For full coverage of the schema.org vocabulary, install schema-dts, Google's own TypeScript types for structured data. For a focused transactional setup, a few narrow interfaces are easier to read and keep your payloads honest. Here is the narrow approach for the three types we need.

emails/lib/schema.ts
// Minimal, transactional-focused schema.org types.
// For the full vocabulary, install: npm i schema-dts

type SchemaContext = "https://schema.org";

export interface ViewActionMarkup {
  "@context": SchemaContext;
  "@type": "EmailMessage";
  potentialAction: {
    "@type": "ViewAction";
    name: string; // Button label, keep it short: "Track order"
    target: string; // HTTPS URL the button opens
    url: string; // Same URL, required by Gmail
  };
  description: string; // Fallback shown if the action cannot render
}

export interface OrderMarkup {
  "@context": SchemaContext;
  "@type": "Order";
  merchant: { "@type": "Organization"; name: string };
  orderNumber: string;
  orderStatus: OrderStatus;
  priceCurrency: string; // ISO 4217, e.g. "USD"
  price: string; // String, not number: "129.00"
  acceptedOffer: Array<{
    "@type": "Offer";
    itemOffered: { "@type": "Product"; name: string };
    price: string;
    priceCurrency: string;
  }>;
  url: string; // Order detail page
  potentialAction?: ViewActionMarkup["potentialAction"];
}

export type OrderStatus =
  | "https://schema.org/OrderProcessing"
  | "https://schema.org/OrderInTransit"
  | "https://schema.org/OrderDelivered"
  | "https://schema.org/OrderProblem"
  | "https://schema.org/OrderReturned";

export interface ParcelDeliveryMarkup {
  "@context": SchemaContext;
  "@type": "ParcelDelivery";
  carrier: { "@type": "Organization"; name: string };
  trackingNumber: string;
  trackingUrl: string;
  expectedArrivalUntil: string; // ISO 8601 with timezone
  deliveryStatus?: "InTransit" | "Delivered";
}

export type EmailSchemaPayload =
  | ViewActionMarkup
  | OrderMarkup
  | ParcelDeliveryMarkup;
Notice priceis a string, not a number. JSON-LD treats numeric prices as strings, and Gmail's validator rejects raw numbers. Typing it as string stops the most common silent failure before it ships.

Step 2: The EmailSchema component

React Email renders standard HTML, so a <script> tag inside the document head survives the render pipeline. Wrap the serialization in one small component and place it inside the React Email <Head>. Putting it in the head keeps it out of the visible body and matches what Gmail's parser expects.

emails/components/EmailSchema.tsx
import * as React from "react";
import type { EmailSchemaPayload } from "../lib/schema";

interface EmailSchemaProps {
  data: EmailSchemaPayload;
}

/**
 * Injects schema.org JSON-LD into the email head.
 * Render this inside <Head> from @react-email/components.
 */
export function EmailSchema({ data }: EmailSchemaProps) {
  return (
    <script
      type="application/ld+json"
      // The markup must be a single, minified JSON object.
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}

Drop it into any template. The email looks identical to before in every client. Only Gmail and other markup-aware clients read the extra block.

emails/OrderConfirmation.tsx
import { Html, Head, Body, Container, Heading, Text } from "@react-email/components";
import { EmailSchema } from "./components/EmailSchema";
import type { OrderMarkup } from "./lib/schema";

interface OrderConfirmationProps {
  customerName: string;
  orderId: string;
  total: string;
  orderUrl: string;
}

export default function OrderConfirmation({
  customerName,
  orderId,
  total,
  orderUrl,
}: OrderConfirmationProps) {
  const schema: OrderMarkup = {
    "@context": "https://schema.org",
    "@type": "Order",
    merchant: { "@type": "Organization", name: "Acme Store" },
    orderNumber: orderId,
    orderStatus: "https://schema.org/OrderProcessing",
    priceCurrency: "USD",
    price: total,
    url: orderUrl,
    acceptedOffer: [
      {
        "@type": "Offer",
        itemOffered: { "@type": "Product", name: "Wireless Earbuds" },
        price: total,
        priceCurrency: "USD",
      },
    ],
    potentialAction: {
      "@type": "ViewAction",
      name: "View order",
      target: orderUrl,
      url: orderUrl,
    },
  };

  return (
    <Html>
      <Head>
        <EmailSchema data={schema} />
      </Head>
      <Body>
        <Container>
          <Heading>Thanks, {customerName}</Heading>
          <Text>Order {orderId} is confirmed. Total: {total}.</Text>
        </Container>
      </Body>
    </Html>
  );
}

What just happened

The template now carries a machine-readable description of the order. Gmail can render an order card and a View order button without the user opening the message. The visible HTML did not change, so Outlook, Apple Mail, and everything else render the body as usual.


Step 3: A go-to action button

If you only ship one thing from this post, ship this. A go-to action is a single button Gmail renders next to the subject line. It works for any transactional email with one obvious next step: confirm a sign-in, review an invoice, track a shipment.

emails/lib/actions.ts
import type { ViewActionMarkup } from "./schema";

export function trackOrderAction(trackingUrl: string): ViewActionMarkup {
  return {
    "@context": "https://schema.org",
    "@type": "EmailMessage",
    potentialAction: {
      "@type": "ViewAction",
      name: "Track order",
      target: trackingUrl,
      url: trackingUrl,
    },
    description: "Track your Acme order",
  };
}
Gmail requires both target and url on the action, and they must point to the same HTTPS address. HTTP links are rejected outright. The button label in name must be short. Long labels get truncated or suppressed.

Step 4: The order card

The Order payload from Step 2 is what drives the card. The fields Gmail leans on hardest are orderNumber, orderStatus, and the acceptedOfferarray. Keep the status in sync with your real order state and you can reuse the exact same template for the confirmation, the "preparing", and the "delivered" emails by changing one field.

emails/lib/order.ts
import type { OrderMarkup, OrderStatus } from "./schema";

interface LineItem {
  name: string;
  price: string;
}

interface BuildOrderArgs {
  orderId: string;
  status: OrderStatus;
  total: string;
  orderUrl: string;
  items: LineItem[];
}

export function buildOrderMarkup({
  orderId,
  status,
  total,
  orderUrl,
  items,
}: BuildOrderArgs): OrderMarkup {
  return {
    "@context": "https://schema.org",
    "@type": "Order",
    merchant: { "@type": "Organization", name: "Acme Store" },
    orderNumber: orderId,
    orderStatus: status,
    priceCurrency: "USD",
    price: total,
    url: orderUrl,
    acceptedOffer: items.map((item) => ({
      "@type": "Offer",
      itemOffered: { "@type": "Product", name: item.name },
      price: item.price,
      priceCurrency: "USD",
    })),
    potentialAction: {
      "@type": "ViewAction",
      name: "View order",
      target: orderUrl,
      url: orderUrl,
    },
  };
}

Because the markup is derived from the same order object you already pass to the template, it can never drift out of sync with the visible body. That is the whole reason to generate it in code instead of pasting a static JSON blob: one source of truth, typed end to end. This is the same discipline behind type-safe email templates, extended to the structured-data layer.


Step 5: Parcel tracking for shipping emails

"Where is my order" is one of the most common support tickets in e-commerce. A ParcelDelivery block answers it from the inbox: carrier, tracking number, and the expected arrival date, all visible without opening the email.

emails/lib/parcel.ts
import type { ParcelDeliveryMarkup } from "./schema";

export function buildParcelMarkup(args: {
  carrierName: string;
  trackingNumber: string;
  trackingUrl: string;
  arrivalIso: string; // e.g. "2026-06-12T18:00:00-04:00"
}): ParcelDeliveryMarkup {
  return {
    "@context": "https://schema.org",
    "@type": "ParcelDelivery",
    carrier: { "@type": "Organization", name: args.carrierName },
    trackingNumber: args.trackingNumber,
    trackingUrl: args.trackingUrl,
    expectedArrivalUntil: args.arrivalIso,
    deliveryStatus: "InTransit",
  };
}
expectedArrivalUntil must be a full ISO 8601 timestamp with a timezone offset. A bare date like 2026-06-12is treated as invalid and the parcel block is dropped. Use your server's timezone-aware date formatting, not a hand-built string.

Step 6: Authenticate and register

Here is the part that separates a working implementation from a broken one. Gmail does not render markup from any sender that asks. The trust gate has three layers, and all three must pass.

Sender authentication

Your domain needs SPF and DKIM passing and aligned, with a published DMARC policy, and a stable, consistent From address. This is the same baseline that keeps you out of spam under the current Gmail and Yahoo bulk-sender rules, so if you followed our SPF, DKIM, and DMARC guide, you are most of the way there. Markup simply will not appear for an unauthenticated sender.

One-time registration with Google

For production rendering at scale, Google asks senders to register the sending domain through its markup whitelisting form. The review checks that you send well-formed markup from an authenticated domain with a clean reputation. While you wait, you can still test end to end:

  • Send a real email from your own Gmail account to the same account. Markup renders for messages you send to yourself without any registration.
  • Use the Gmail Markup Tester in Google's developer console to paste a template and preview exactly how the card or action renders.

Reputation

Even authenticated and registered, low-reputation senders get markup suppressed. Keep your spam complaint rate under the 0.3 percent threshold and your engagement healthy. The same sender reputation signals that protect inbox placement protect markup rendering.

The order of operations

Authenticate first, validate the markup with the tester second, register third. Doing it in any other order means you will spend a day wondering why a technically perfect JSON-LD block renders nothing.


JSON-LD or microdata?

Schema.org markup can be expressed two ways in email: JSON-LD in a script tag, or microdata attributes scattered across your HTML elements. For React Email, JSON-LD wins decisively.

JSON-LD (recommended)
  • One isolated block, decoupled from your layout
  • Generated from typed data, so it cannot drift from the body
  • Gmail's preferred format per Google's own docs
  • Easy to validate, diff, and unit test
Microdata
  • Markup attributes spread across many JSX elements
  • Refactoring the layout silently breaks the markup
  • Hard to type and easy to typo
  • No clean way to test in isolation
If a tutorial has you wrapping itemprop and itemscope attributes around your email layout, that is microdata. Skip it. The component from Step 2 does the same job without welding your structured data to your layout.

What about Outlook and Apple Mail?

Gmail has the deepest support, but it is no longer alone. Microsoft has documented schema.org support for Outlook and Microsoft 365, which can surface booking and delivery details from the same vocabulary. Apple Mail does not render schema cards today, but it does not choke on the script block either, so there is no harm in shipping it.

So you build the markup once, pick up the upside in Gmail and Outlook today, and lose nothing in the clients that ignore it. There is no per-client fork to maintain, because the body HTML never changes. That is the same logic behind designing for email client compatibility generally: build to the standard, degrade gracefully everywhere else.


Validate before every send

Because failures are silent, treat the markup like code that ships: test it. A tiny assertion in your render pipeline catches the classes of error that Gmail will not warn you about.

emails/lib/validateSchema.ts
import type { EmailSchemaPayload } from "./schema";

/**
 * Cheap pre-send checks for the silent-failure traps.
 * Run this in a test or before render, not as a replacement
 * for Google's Markup Tester.
 */
export function assertValidMarkup(data: EmailSchemaPayload): void {
  const json = JSON.stringify(data);

  if (!json.includes("https://schema.org")) {
    throw new Error("Schema @context must be https://schema.org");
  }
  // Every URL in the payload must be HTTPS.
  const urls = json.match(/"https?:\/\/[^"]+"/g) ?? [];
  for (const raw of urls) {
    const url = raw.replace(/"/g, "");
    if (url.startsWith("http://") && !url.startsWith("http://schema.org")) {
      throw new Error("Markup URLs must use HTTPS: " + url);
    }
  }
  // Prices must be strings, never numbers.
  if ("price" in data && typeof (data as { price: unknown }).price !== "string") {
    throw new Error("price must be a string, e.g. \"129.00\"");
  }
}
This local check is a fast first line of defense, not the final word. The authoritative validator is Google's Email Markup Tester, which renders the actual card. Run both: the assertion in CI, the tester before you flip a new template live.

Key takeaway

The schema.org email markup checklist:

  • Type your payloads so a typo is a build error, not a silent no-op
  • Inject JSON-LD through one reusable component placed in the email <Head>
  • Start with a go-to ViewAction: highest value for the least effort
  • Generate markup from the same order object the body uses, never paste static JSON
  • Prices are strings, URLs are HTTPS, arrival dates are full ISO 8601 with a timezone
  • Authenticate (SPF, DKIM, DMARC) first, validate second, register with Google third
  • Validate every template in Google's Markup Tester before it goes live
R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates