Display reviews

How to Embed Google Reviews in Next.js (App Router, Server-Rendered)

Render Google reviews in a Next.js Server Component: an HTML fragment or a JSON API, cached with revalidate, API key kept server-side, readable by AI crawlers.

Updated

Search for “embed Google reviews in Next.js” and you'll mostly find client-side widgets: a <script> tag or a useEffect that fetches reviews after the page loads. That throws away the main reason to use Next.js. With the App Router, a Server Component can fetch your reviews on the server, cache them, and ship them inside the HTML.

Which approach should you use?

Two approaches keep your reviews readable by Google and AI crawlers. The third one doesn't.

ApproachReviews in initial HTMLReadable by AI crawlersAPI keyRevys planDesign control
HTML fragment (/api/embed/ID/html) in a Server ComponentYesYesNoFree and ProLayout set in Revys
JSON API (/api/v1/reviews) rendered with React on the serverYesYesYesProFull: your own components
JavaScript snippet or useEffectNoNoNoFree and ProRevys iframe

The HTML fragment is the fastest: about ten lines, and you keep the five layouts of the Google reviews widget (grid, list, carousel, wall, badge). Use the JSON API when your design system calls for your own components.

Option 1: render the HTML fragment in a Server Component

This is the code the Revys dashboard gives you for a Next.js site: the Put on my website assistant recognizes Next.js and preselects “Next.js (App Router)”. Revys returns the fully rendered widget: review markup, CSS scoped to the widget, and JSON-LD, with zero JavaScript.

  1. Get your widget's public ID

    In Revys, open My businesses › your business › On my website, click Put on my website and enter your site address. Revys recognizes Next.js (if it doesn't, pick Next.js under “Not right?”) and preselects “Next.js (App Router)” under “Your site is built with…”. The code shown already contains the URL with your ID.

  2. Create the server component

    Create components/revys-reviews.tsx. No "use client" directive: this is a Server Component.

    // components/revys-reviews.tsx (Server Component)
    export async function RevysReviews() {
      try {
        const res = await fetch("https://revys.io/api/embed/YOUR_WIDGET_ID/html", {
          next: { revalidate: 3600 },
          signal: AbortSignal.timeout(2000),
        });
        if (!res.ok) return null;
        return <div dangerouslySetInnerHTML={{ __html: await res.text() }} />;
      } catch {
        return null;
      }
    }
  3. Drop it into a page

    Import it wherever the reviews should appear:

    // app/page.tsx
    import { RevysReviews } from "@/components/revys-reviews";
    
    export default function Page() {
      return (
        <main>
          <h1>Acme Plumbing</h1>
          <RevysReviews />
        </main>
      );
    }
  4. Check the HTML you ship

    After a production build, run curl -s https://your-site.com/ | grep -c "revys-w" or view the page source. The text of your reviews should be in it.

Three lines in that component matter:

  • next: { revalidate: 3600 } caches the response for an hour. Per the Next.js docs, the number is the resource's maximum cache lifetime in seconds. Revys refreshes your Google reviews about every hour, so a shorter cache buys you nothing.
  • AbortSignal.timeout(2000) gives up after two seconds. Your page should never wait on Revys.
  • return null on failure: if Revys doesn't answer, the page renders without reviews instead of erroring.

dangerouslySetInnerHTML is intentional here. The fragment has no executable script and no event handlers, and Revys escapes the review text. You trust exactly one hard-coded source, revys.io.

With cacheComponents enabled (Next.js 16)

If your project turns on cacheComponents in next.config.ts, declare caching with the "use cache" directive and cacheLife, which became stable in Next.js 16. The "hours" profile revalidates every hour:

// lib/revys.ts
import { cacheLife } from "next/cache";

export async function getRevysHtml() {
  "use cache";
  cacheLife("hours");
  const res = await fetch("https://revys.io/api/embed/YOUR_WIDGET_ID/html", {
    signal: AbortSignal.timeout(2000),
  });
  return res.ok ? res.text() : "";
}

Your component then calls getRevysHtml() inside a try/catch and renders the HTML the same way.

Option 2: the JSON API, rendered with React on the server

GET /api/v1/reviews returns your reviews, the average rating and a ready-made schemaOrg object. It's included in the Pro plan (10,000 calls per month per listing) and authenticates with an API key.

  1. Create an API key

    In Revys, open API & developers (left-hand menu) and create a key. It looks like rv_<prefix>_<secret> and is shown only once.

  2. Store it server-side

    Add it to .env.local (and to your host's environment variables) along with the widget's public ID:

    REVYS_API_KEY=rv_xxx_...
    REVYS_WIDGET_ID=YOUR_WIDGET_ID

    No NEXT_PUBLIC_ prefix: the Next.js docs explain that those variables are inlined into the JavaScript bundle sent to the browser.

  3. Write the component

    Install the server-only guard (npm install server-only) so the build fails if this file ever gets imported into a client component.

    // components/revys-reviews.tsx (Server Component)
    import "server-only";
    
    type RevysReview = {
      id: string;
      authorName: string;
      rating: number;
      text: string | null;
      reviewedAt: string | null;
      ownerReply: string | null;
    };
    
    type RevysData = {
      reviews: RevysReview[];
      aggregate: { count: number; average: number };
      schemaOrg: Record<string, unknown>;
    };
    
    async function getRevysReviews(): Promise<RevysData | null> {
      try {
        const res = await fetch(
          `https://revys.io/api/v1/reviews?widget=${process.env.REVYS_WIDGET_ID}`,
          {
            headers: { Authorization: `Bearer ${process.env.REVYS_API_KEY}` },
            next: { revalidate: 3600 },
            signal: AbortSignal.timeout(2000),
          },
        );
        if (!res.ok) return null;
        const json = (await res.json()) as { data: RevysData };
        return json.data;
      } catch {
        return null;
      }
    }
    
    export async function RevysReviews() {
      const data = await getRevysReviews();
      if (!data || data.reviews.length === 0) return null;
      const { reviews, aggregate, schemaOrg } = data;
    
      return (
        <section aria-labelledby="reviews-title">
          <h2 id="reviews-title">What our customers say</h2>
          <p>
            {aggregate.average.toFixed(1)}/5 from {aggregate.count} Google reviews
          </p>
          <ul>
            {reviews.map((r) => (
              <li key={r.id}>
                <p>
                  <strong>{r.authorName}</strong>{" "}
                  <span aria-label={`${r.rating} out of 5`}>
                    {"★".repeat(r.rating)}
                    {"☆".repeat(5 - r.rating)}
                  </span>
                  {r.reviewedAt ? (
                    <time dateTime={r.reviewedAt}>
                      {" "}· {new Date(r.reviewedAt).toLocaleDateString("en-US")}
                    </time>
                  ) : null}
                </p>
                {r.text ? <p>{r.text}</p> : null}
                {r.ownerReply ? (
                  <p>
                    <em>Response from the owner:</em> {r.ownerReply}
                  </p>
                ) : null}
              </li>
            ))}
          </ul>
          <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{
              __html: JSON.stringify(schemaOrg).replace(/</g, "\\u003c"),
            }}
          />
        </section>
      );
    }

API details that matter in production:

  • ?widget= applies the widget's filters (minimum rating, number of reviews). Reviews come newest first, without the ones you've hidden.
  • aggregate covers all of the listing's reviews, so the average matches your real Google rating even if the widget only shows 4- and 5-star reviews.
  • schemaOrg is an Organization object with no aggregateRating, in line with Google's self-serving review rule. The .replace(/</g, "\\u003c") follows the Next.js docs' advice against tag injection.
  • If you go over the monthly quota, the API returns the last successful response for the same target (with an X-Revys-Quota: exceeded header), so your reviews stay up.

With revalidate: 3600, each server instance makes at most 24 calls a day, roughly 720 a month.

Why skip client-side widgets in Next.js?

Because they cancel out server rendering. A useEffect that calls an API, or Revys's own JavaScript snippet, adds reviews after load. The initial HTML doesn't have them.

  • AI crawlers read raw HTML. Vercel's December 2024 analysis found no major AI crawler that executes JavaScript.
  • Google does execute JavaScript, but in a rendering queue separate from the first crawl, as its JavaScript SEO documentation explains.
  • The Revys snippet injects an iframe that points to revys.io, so the content isn't on your domain.

A client-side call would also expose your API key to every visitor. For more on what AI search engines pick up, see how reviews get you cited in AI search.

What about Astro, Node or PHP?

The same HTML fragment works with any server. The dashboard has ready-made code for Astro, Node.js/Express, Python, Ruby and a Cloudflare Worker; the PHP, HTML and other platforms page covers PHP and static sites. On WordPress, use the WordPress plugin instead. To compare every method, read how to embed Google reviews on your website. Pro starts at $7.90/month for your first listing (pricing).

FAQ

Do I need an API key to show Google reviews in Next.js?

Not for the HTML fragment: /api/embed/ID/html is public and works on the Free plan. The JSON API at /api/v1/reviews is part of the Pro plan and needs an Authorization: Bearer header.

Where should the Revys API key live in a Next.js project?

In a server-only environment variable such as REVYS_API_KEY in .env.local, read only inside a Server Component. Never prefix it with NEXT_PUBLIC_, or Next.js inlines it into the JavaScript sent to browsers.

Why not load reviews with useEffect or a client-side widget?

Because the reviews then aren't in the initial HTML. Major AI crawlers don't execute JavaScript, and Revys's JavaScript snippet loads an iframe hosted on revys.io, so the content isn't on your domain.

How many API calls does a one-hour cache use?

At most 24 calls a day per server instance, roughly 720 a month, far below the Pro quota of 10,000 calls per month per listing.

Does this work with the Pages Router?

Yes. Make the same request in getStaticProps with revalidate: 3600 (incremental static regeneration) and pass the HTML or the reviews to the page as props.

Is there a React component or npm package for Google reviews?

Revys doesn't publish an npm package. The component on this page is about 20 lines (HTML fragment) or 70 lines (JSON API) of plain React that you copy into your codebase.

Sources

  1. fetch: options.next.revalidate (Next.js docs)
  2. How to implement JSON-LD in your Next.js application (Next.js docs)
  3. Environment Variables (Next.js docs)
  4. cacheLife (Next.js docs)
  5. Understand JavaScript SEO basics (Google Search Central)
  6. The rise of the AI crawler (Vercel, December 2024)

Related