StoreOSv0.15.0
Plugins

Reading plugins from your storefront

Load GA4, GTM, pixels, chat widgets, and verification tags that a merchant configured in the StoreOS console.

The one call you need

Plugin state reaches your storefront through the tenant payload — the same object you already fetch for the store name, logo, and delivery charges:

import { store } from "@/lib/store";

const tenant = await store.getTenant();
//    GET /api/v1/tenant

There is no separate plugins endpoint, and nothing to install on your side. If the merchant configured Meta Pixel in the console, tenant.analytics.metaPixelId is populated on your next request.


What you get

interface Tenant {
  // …name, logo, seo, charges, orderFlow…

  /** Public analytics IDs only — never tokens. */
  analytics?: {
    gtmContainerId?: string | null;
    gaMeasurementId?: string | null;
    popupsmartAccountId?: string | null;
    hotjarSiteId?: string | null;
    metaPixelId?: string | null;
    clarityProjectId?: string | null;
    luckyOrangeSiteId?: string | null;
    shareChatPixelId?: string | null;
  } | null;

  support?: {
    intercomAppId?: string | null;
    tawkPropertyId?: string | null;
    tawkWidgetId?: string | null;
    whatsappPhoneNumber?: string | null;
    whatsappPrefilledMessage?: string | null;
  } | null;

  searchConsole?: { metaContent?: string | null } | null;
  facebookDomainVerification?: { content?: string | null } | null;
}
PluginField
Google Tag Manageranalytics.gtmContainerId
Google Analytics 4analytics.gaMeasurementId
Popupsmartanalytics.popupsmartAccountId
Hotjaranalytics.hotjarSiteId
Meta Pixelanalytics.metaPixelId
Microsoft Clarityanalytics.clarityProjectId
Lucky Orangeanalytics.luckyOrangeSiteId
ShareChat Pixelanalytics.shareChatPixelId
Intercomsupport.intercomAppId
Tawk.tosupport.tawkPropertyId + support.tawkWidgetId
WhatsApp chatsupport.whatsappPhoneNumber, support.whatsappPrefilledMessage
Google Search ConsolesearchConsole.metaContent
Facebook domain verificationfacebookDomainVerification.content

Popupsmart is stored under plugins.marketing but surfaces under analytics in the tenant payload — read it from analytics.popupsmartAccountId.

Two nulls, not one

analytics and support are null when nothing in that group is configured, and an object with null members when some are. So check both levels, or use optional chaining throughout:

const ga = tenant.analytics?.gaMeasurementId ?? null;

What you will never get

Courier keys, payment credentials, SMTP passwords, and Google OAuth tokens are not in this payload. They are not redacted at the edge — the storefront API never maps them in.

Want to…Do this instead
Book a parcel with PathaoPlace the order; StoreOS books it server-side
Charge a card through SSLCOMMERZUse createOrder with paymentMethod: "ONLINE"
Send an order SMSStoreOS sends it from the configured notification plugin

If you find yourself wanting a secret in the browser, the operation belongs on StoreOS, not in your storefront.


Next.js — analytics in the layout

Fetch once in the root layout and render only what the merchant turned on:

// app/layout.tsx
import Script from "next/script";
import { store } from "@/lib/store";

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const tenant = await store.getTenant();
  const a = tenant.analytics;

  return (
    <html lang="en">
      <body>
        {children}

        {a?.gtmContainerId ? (
          <Script id="gtm" strategy="afterInteractive">
            {`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
            var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;
            j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);
            })(window,document,'script','dataLayer','${a.gtmContainerId}');`}
          </Script>
        ) : null}

        {a?.gaMeasurementId ? (
          <>
            <Script
              src={`https://www.googletagmanager.com/gtag/js?id=${a.gaMeasurementId}`}
              strategy="afterInteractive"
            />
            <Script id="ga4" strategy="afterInteractive">
              {`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}
              gtag('js',new Date());gtag('config','${a.gaMeasurementId}');`}
            </Script>
          </>
        ) : null}

        {a?.clarityProjectId ? (
          <Script id="clarity" strategy="afterInteractive">
            {`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
            t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
            y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
            })(window,document,"clarity","script","${a.clarityProjectId}");`}
          </Script>
        ) : null}
      </body>
    </html>
  );
}

A merchant who never installs an analytics plugin ships zero third-party scripts. That is the point of gating each block on its own ID rather than a single feature flag.


Next.js — verification tags in metadata

Search Console and Facebook verification are <meta> tags, not scripts:

// app/layout.tsx
import type { Metadata } from "next";
import { store } from "@/lib/store";

export async function generateMetadata(): Promise<Metadata> {
  const tenant = await store.getTenant();

  const other: Record<string, string> = {};
  if (tenant.searchConsole?.metaContent) {
    other["google-site-verification"] = tenant.searchConsole.metaContent;
  }
  if (tenant.facebookDomainVerification?.content) {
    other["facebook-domain-verification"] =
      tenant.facebookDomainVerification.content;
  }

  return { title: tenant.name, other };
}

The console already normalizes what the merchant pasted — if they pasted the whole <meta …> tag, the stored value is just the token. Render it as an attribute value; do not re-parse it.


Support widgets

"use client";

import Script from "next/script";
import type { Tenant } from "@storeos/storefront-client";

export function SupportWidgets({ support }: { support: Tenant["support"] }) {
  if (!support) return null;

  const waHref = support.whatsappPhoneNumber
    ? `https://wa.me/${support.whatsappPhoneNumber}` +
      (support.whatsappPrefilledMessage
        ? `?text=${encodeURIComponent(support.whatsappPrefilledMessage)}`
        : "")
    : null;

  return (
    <>
      {support.intercomAppId ? (
        <Script id="intercom" strategy="lazyOnload">
          {`window.intercomSettings={app_id:"${support.intercomAppId}"};`}
        </Script>
      ) : null}

      {support.tawkPropertyId && support.tawkWidgetId ? (
        <Script
          src={`https://embed.tawk.to/${support.tawkPropertyId}/${support.tawkWidgetId}`}
          strategy="lazyOnload"
          crossOrigin="*"
        />
      ) : null}

      {waHref ? (
        <a href={waHref} target="_blank" rel="noopener noreferrer">
          Chat on WhatsApp
        </a>
      ) : null}
    </>
  );
}

Tawk.to needs both IDs — render it only when both are present, which is also how the API decides whether support is non-null.

whatsappPhoneNumber is already digits-only (the console strips +, spaces, and dashes), so it drops straight into a wa.me/ URL.


React SDK

On the client, useStorefrontTenant() wraps the same call in a React Query hook with a 30-second stale time:

"use client";

import { useStorefrontTenant } from "@storeos/storefront-client/react";

export function PixelBadge() {
  const { data: tenant, isLoading } = useStorefrontTenant();
  if (isLoading || !tenant?.analytics?.metaPixelId) return null;
  return <span>Pixel {tenant.analytics.metaPixelId} active</span>;
}

For scripts, prefer the server-side fetch in the layout — it renders the tag in the initial HTML instead of after hydration. See the React SDK.


Caching

Plugin IDs change when a merchant edits settings, which is rare but not never. Treat the tenant call like any other semi-static fetch:

// Next.js App Router
export const revalidate = 300;

Five minutes is a reasonable default: a merchant who adds a pixel sees it live within one revalidation window, and you are not re-fetching the tenant on every request.

On this page