Skip to content
Navo24 Navo24

Guides

Embed the Navo apps on a client site

Tracking, freight rates, sailing schedules and the AI assistant on a client's own website, in the client's brand, with one script tag and a domain-locked key.

The same apps you see on the example forwarder site can run on a client’s own website: a search widget for the home page, plus full pages for tracking, freight rates and sailing schedules. Each one is shown in the client’s name, logo and colour. Every view has its own link, so a visitor can send a colleague one container, one rate search, one rate, one sailing, one vessel or one port, and it opens on the client’s site.

No API key or server is needed on the client’s side. The embed key is public and only works on the domains we list for it.

What the client sends us

ItemExampleRequired
Company name as visitors should see itAcme LogisticsYes
Domains the apps will run on (production, staging)acme.com, www.acme.com, staging.acme.comYes
Logo, SVG or PNG, square mark or horizontalhttps://acme.com/logo.svgYes
Brand colour, one hex value#0e6ba8Yes
More colours, fonts, corner roundingsee Look and feelOptional
E-mail that receives booking requests[email protected]For freight rates
Webhook URL for booking requests (to their CRM/TMS)https://acme.com/hooks/navoOptional
Which apps they wantany of tracking, freight, schedules; AI mode on or offYes
Usage limit per app, per month or per yeartracking: 300 unique shipments a monthYes
Pages where each app will live/track, /rates, /schedulesRecommended
”Powered by Navo” shown or hiddenshownDefault: shown

We create the key and send back the snippets below, with the key filled in.

What one key can carry

A key opens one app or several, and AI mode is a separate switch:

PackageApps on the keyWhat the visitor gets
Tracking onlytrackingTracking page; the search widget shows the tracking box with no tabs and no AI switch
Tracking with AItracking, aiThe same, plus AI mode in the search widget and “Ask AI” on shipments
Rates and schedulesfreight, schedulesTwo tabs in the search widget, the rates and schedules pages
Everythingtracking, freight, schedules, aiAll three tabs, AI mode, all pages

The widget is built from the key: a tab, a page or an AI button the key does not open is not drawn at all, and our server refuses its data even if someone forces the request.

Usage limits

Each app on a key has its own limit, per calendar month or per calendar year (UTC). What counts as one unit:

AppOne unitFree repeats
trackingOne unique shipment (container, booking or B/L number)The same shipment for the rest of the period
freightOne rate search (lane and equipment)The same search the same day
schedulesOne schedule search (route, vessel or port)The same search the same day
aiOne question to the AI assistantNone

Example: tracking at 300 unique shipments a month. The client’s visitors can open and refresh those 300 shipments as often as they like all month; shipment 301 is refused until the 1st of next month. When an app reaches its limit, visitors see “This service is paused on this site for now. Please contact .” Other apps on the key keep working. We see usage per app and can raise a limit at any moment, with no change on the client’s site.

Install

Put the loader once on every page that shows an app:

<script src="https://navo24.com/embed.js" async></script>

Then a container where the app should appear.

Search widget (home page hero, or any page). One box with a tab for each app on the key (tracking, rates, schedules) and AI mode if the key has it. With one app there are no tabs:

<div
  data-navo-app="search"
  data-navo-key="nve_YOUR_KEY"
  data-navo-pages='{"tracking":"/track","freight":"/rates","schedules":"/schedules"}'
></div>

With data-navo-pages the widget sends visitors to the client’s own pages. Without it, the result opens right inside the widget’s frame.

Tracking page (/track):

<div data-navo-app="tracking" data-navo-key="nve_YOUR_KEY"></div>

Freight rates page (/rates):

<div data-navo-app="freight" data-navo-key="nve_YOUR_KEY"></div>

Sailing schedules page (/schedules), with three tabs: by route, by vessel, by port:

<div data-navo-app="schedules" data-navo-key="nve_YOUR_KEY"></div>

Use the same data-navo-pages value on every app so cross-links (for example “Show rates” on a sailing, or “Vessel schedule” on a shipment) land on the right page of the client’s site.

Options

AttributeWhat it does
data-navo-appsearch, tracking, freight or schedules.
data-navo-keyThe site’s embed key.
data-navo-pagesJSON map of app to page path on the client’s site.
data-navo-heightFixed height in px. By default the frame grows with its content; tracking is a full map, 680 px.
data-navo-syncoff keeps the page URL unchanged while the visitor clicks around.
data-navo-themeJSON with colours, fonts and corners, see Look and feel.

Look and feel

By default the apps take the client’s brand colour from the key. To match the site more closely, add data-navo-theme to the container: colours, fonts, corner rounding and a dark mode.

<div
  data-navo-app="freight"
  data-navo-key="nve_YOUR_KEY"
  data-navo-theme='{"accent":"#e4002b","text":"#1a1a1a","heading":"#002f6c","font":"Inter","radius":6,"buttonRadius":6}'
></div>

Every key is optional. A value that is not valid is ignored, the rest still applies.

KeyWhat it changesExample
accentButtons, links, active tabs, switches, focus ring. Hover and pressed shades are computed from it"#e4002b"
textMain text"#1a1a1a"
mutedSecondary text: labels, hints, dates"#6b7280"
headingHeadings, carrier and vessel names, map lines"#002f6c"
backgroundPage background of the app. "transparent" lets the client’s own background show through"#ffffff"
surfaceCards, inputs, table stripes"#f5f7fa"
borderBorders and dividers"#e5e7eb"
successPositive states: delivered, on time, cheapest"#15803d"
radiusCorner rounding of cards and panels, px (0 to 40)8
buttonRadiusCorner rounding of buttons and inputs, px. 999 gives pills (the default)6
fontBody font. Any Google Fonts family is loaded for you; "system" uses the visitor’s system font"Inter"
headingFontFont for headings, same rules. Without it headings use font"Playfair Display"
fontCssYour own font stylesheet (https:// URL with @font-face rules), for fonts that are not on Google Fonts. Then font and headingFont name families from that file"https://acme.com/fonts/brand.css"
mode"dark" for sites with a dark design"dark"

Colours are hex: #rgb, #rrggbb or #rrggbbaa.

Ready-made starting points:

<!-- Corporate: square corners, navy headings -->
data-navo-theme='{"accent":"#003a70","heading":"#003a70","font":"Source Sans 3","radius":2,"buttonRadius":2}'

<!-- Soft: rounded, light grey cards -->
data-navo-theme='{"accent":"#0f766e","surface":"#f3f4f6","font":"Nunito","radius":16,"buttonRadius":999}'

<!-- Dark site -->
data-navo-theme='{"mode":"dark","accent":"#f59e0b","background":"#0b1220","surface":"#111a2e","border":"#1f2a44","text":"#e5e7eb","muted":"#94a3b8"}'

<!-- Blend into the page: the app draws no background of its own -->
data-navo-theme='{"background":"transparent","font":"system"}'

One theme for all pages. We can store the theme on the key as well. Then every container on every page gets it without the attribute, and we can adjust it later with no change on the client’s site. data-navo-theme on a container still wins over the key’s theme, key by key.

The theme only reaches the app inside the frame: it never changes the client’s own page.

React and Next.js, with search indexing

Use this when the client’s site is built with React or Next.js and the app’s content should be indexed as the client’s page (for example, a page per lane, “Shanghai to Hamburg sailing schedule”).

It is two parts:

  1. Snapshot. The client’s server fetches the lane’s data from us as plain HTML (schedule table or rate table, a heading, a one-line summary and schema.org markup) and puts it into its own page. Search engines index it as the client’s content, on the client’s URL.
  2. App. In the browser the interactive app mounts above it, with search, filters, details and booking. The snapshot stays on the page as a collapsible table under the app, so crawlers and visitors see the same content.

Snapshot endpoint

GET https://api.navo24.com/v1/demo/embed/snapshot
    ?key=nve_YOUR_KEY
    &app=schedules            schedules | freight
    &origin=CNSHA             UN/LOCODE
    &destination=DEHAM        UN/LOCODE
    &container=40HC           freight only: 20GP | 40GP | 40HC
    &host=acme.com            the client's domain, must be on the key
    &format=html              optional: the HTML block only

Without format=html the answer is JSON:

{
  "data": {
    "app": "schedules",
    "title": "Sailing schedule Shanghai to Hamburg",
    "description": "153 sailings from Shanghai (CNSHA) to Hamburg (DEHAM) in the next 6 weeks, 10 carriers, transit from 34 days.",
    "items": [{ "carrier": "Yang Ming", "vessel": "MSC OLIVIA", "voyage": "GL642W", "etd": "2026-10-24T00:00:00", "eta": "2026-12-03T00:00:00", "transit_days": 40, "direct": true }],
    "html": "<section class=\"navo-snapshot\">…</section>",
    "as_of": "2026-10-22T08:52:51Z"
  }
}

Use title and description for the page’s <title> and meta description, html for the body. The answer can be cached for 30 minutes (Cache-Control: max-age=1800). A snapshot counts as one search against the app’s limit; the same lane the same day is free. Tracking has no snapshot: shipments are private to the visitor.

Next.js (App Router)

// app/schedules/[lane]/page.tsx   e.g. /schedules/cnsha-deham
import NavoApp from './NavoApp';

const KEY = 'nve_YOUR_KEY';
const API = 'https://api.navo24.com/v1/demo/embed/snapshot';

async function snapshot(lane: string) {
  const [origin, destination] = lane.toUpperCase().split('-');
  const q = new URLSearchParams({ key: KEY, app: 'schedules', origin, destination, host: 'acme.com' });
  const r = await fetch(`${API}?${q}`, { next: { revalidate: 1800 } });
  return r.ok ? (await r.json()).data : null;
}

export async function generateMetadata({ params }: { params: { lane: string } }) {
  const s = await snapshot(params.lane);
  return s ? { title: `${s.title} | Acme Logistics`, description: s.description } : {};
}

export default async function Page({ params }: { params: { lane: string } }) {
  const s = await snapshot(params.lane);
  const [origin, destination] = params.lane.toUpperCase().split('-');
  return (
    <main>
      <NavoApp
        app="schedules"
        embedKey={KEY}
        view={{ origin, destination }}
        snapshotHtml={s?.html}
        theme={{ accent: '#e4002b', font: 'Inter' }}
      />
    </main>
  );
}
// app/schedules/[lane]/NavoApp.tsx
'use client';
import { useEffect, useRef, useState } from 'react';

const SRC = 'https://navo24.com/embed.js';

function loadNavo(): Promise<void> {
  const w = window as any;
  if (w.NavoEmbed) return Promise.resolve();
  return new Promise((ok) => {
    let s = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`);
    if (!s) { s = document.createElement('script'); s.src = SRC; s.async = true; document.head.appendChild(s); }
    s.addEventListener('load', () => ok(), { once: true });
  });
}

type Props = {
  app: 'search' | 'tracking' | 'freight' | 'schedules';
  embedKey: string;
  view?: Record<string, string>;          // opens this lane right away
  pages?: Record<string, string>;
  theme?: Record<string, string | number>;
  snapshotHtml?: string;                  // server-rendered data for search engines
};

export default function NavoApp({ app, embedKey, view, pages, theme, snapshotHtml }: Props) {
  const box = useRef<HTMLDivElement>(null);
  const [live, setLive] = useState(false); // until the app is up, the table is open
  useEffect(() => {
    if (view && !new URLSearchParams(location.search).get('navo')) {
      const u = new URL(location.href);
      u.searchParams.set('navo_app', app);
      u.searchParams.set('navo', new URLSearchParams(view).toString());
      history.replaceState(history.state, '', u);
    }
    loadNavo().then(() => { (window as any).NavoEmbed.mount(); setLive(true); });
  }, [app, embedKey]);
  return (
    <>
      <div
        ref={box}
        data-navo-app={app}
        data-navo-key={embedKey}
        data-navo-pages={pages ? JSON.stringify(pages) : undefined}
        data-navo-theme={theme ? JSON.stringify(theme) : undefined}
      />
      {snapshotHtml && (
        <details open={!live}>
          <summary>All results as a table</summary>
          <div dangerouslySetInnerHTML={{ __html: snapshotHtml }} />
        </details>
      )}
    </>
  );
}

How this looks to a search engine: the page’s own title and description, a heading and a full table of sailings (or rates) in the HTML, with Dataset markup. How it looks to a visitor: the live app, already open on that lane, with the same results as a folded table under it. Visitors with JavaScript off see the open table. The table is folded, not hidden, so the page shows crawlers nothing a visitor cannot open.

The snapshot HTML comes from our server with every value escaped, so it is safe to insert. Style it with the .navo-snapshot and .navo-snapshot-table classes if it is ever shown.

Other server frameworks (Remix, Astro, Nuxt, PHP, WordPress) work the same way: fetch the snapshot on the server, print html into the page, put the container next to it.

Plain React (no server rendering)

When search indexing does not matter (a tracking page behind a menu, an intranet), the component without the snapshot is enough:

The loader works the same in a single-page app (React, Vue, Svelte): render the container, then call window.NavoEmbed.mount().

import { useEffect } from 'react';

const SRC = 'https://navo24.com/embed.js';

function loadNavo() {
  if (window.NavoEmbed) return Promise.resolve();
  return new Promise((ok) => {
    let s = document.querySelector(`script[src="${SRC}"]`);
    if (!s) { s = document.createElement('script'); s.src = SRC; s.async = true; document.head.appendChild(s); }
    s.addEventListener('load', ok, { once: true });
  });
}

export function NavoApp({ app, embedKey, pages }) {
  useEffect(() => { loadNavo().then(() => window.NavoEmbed.mount()); }, [app, embedKey]);
  return (
    <div
      data-navo-app={app}
      data-navo-key={embedKey}
      data-navo-pages={pages ? JSON.stringify(pages) : undefined}
    />
  );
}

// <NavoApp app="tracking" embedKey="nve_YOUR_KEY" pages={{ tracking: '/track' }} />

In Next.js put it in a client component ('use client').

Why a frame

The loader draws each app inside a frame from navo24.com. This is how Stripe Checkout, Google Maps embeds and YouTube players work, for the same reasons:

  • Domain lock. The browser itself refuses to show the app on a site that is not on the key’s list (frame-ancestors). A script component loaded into the client’s page cannot be locked this way.
  • No style clashes. The client’s CSS cannot break the app and our CSS cannot touch their site.
  • Updates without the client. New carriers, fixes and features reach every site the same day; nothing to upgrade on the client’s side.
  • Search engines. Content inside a frame is not indexed as the client’s page, the same as with any map or video embed. When that matters, add the server-rendered snapshot, see React and Next.js, with search indexing.

Visitors do not log in inside the frame, so browser blocking of third-party cookies does not affect it.

While a visitor works in an app, the page URL follows them: ?navo_app=<app>&navo=<view>. The Copy link buttons inside the apps produce the same kind of link. Examples on a client page /rates:

ViewLink
A container/track?navo_app=tracking&navo=ref%3DZMOU8956106
Rate search on a lane/rates?navo_app=freight&navo=origin%3DCNSHA%26destination%3DDEHAM%26container%3D40HC
One ratethe search above plus %26quote%3D<id>
Sailing search on a lane/schedules?navo_app=schedules&navo=origin%3DCNSHA%26destination%3DDEHAM
One sailingthe search above plus %26sailing%3D<id>
A vessel/schedules?navo_app=schedules&navo=by%3Dvessel%26imo%3D9893890
A port/schedules?navo_app=schedules&navo=by%3Dport%26port%3DSGSIN

A shared rate or sailing keeps opening after it has left the live results; it is shown with the date its data was taken.

Booking requests

Book on a rate opens a request form (contact, company, e-mail, phone, number of containers, cargo-ready date, cargo, notes). On submit:

  • an e-mail goes to the client’s booking address, with the route, equipment, carrier, the price the visitor saw and the visitor’s contact details. Reply-To is the visitor, so the client answers straight from their mailbox;
  • if a webhook is set, the same request is POSTed to it as JSON:
{
  "type": "booking.requested",
  "data": {
    "reference": "BR-261020-4F2A1",
    "received_at": "2026-10-20T14:05:11Z",
    "site": "acme.com",
    "contact": { "name": "…", "company": "…", "email": "…", "phone": "…" },
    "shipment": { "origin": "Shanghai (CNSHA)", "destination": "Hamburg (DEHAM)", "container": "40ft HC", "quantity": "2", "ready_date": "2026-11-03", "cargo": "…", "notes": "…" },
    "rate": { "id": "…", "carrier": "…", "price": 2140, "currency": "USD", "transit_days": 34, "departure": "2026-11-06", "valid_until": "2026-11-15" }
  }
}

The visitor sees a confirmation with the reference. Nothing is charged and no carrier booking is made: the client confirms space and replies.

Without a booking e-mail or webhook the form stays a demo and says so.

What visitors see, and what they do not

  • Carrier names and logos, prices, transit times, validity and what a rate includes.
  • Rates with no carrier named are shown as ” Select”, with the client’s logo.
  • Rate sources and platforms are never shown. They are removed on our server before data reaches the browser.

Security

Domain lock. The app pages answer with Content-Security-Policy: frame-ancestors listing only the key’s domains, so a browser refuses to show them anywhere else. Data requests carry the key and the parent page’s host and are refused when the host is not on the list. A key copied into another site does not work there.

Limits. Each app on a key has its own monthly or yearly limit (see Usage limits). On top of that, every key has a safety cap of 50,000 requests a month and per-visitor rate limits against scripted abuse; over those the app shows a short “try again” message.

Rotation. If a key leaks or a site changes hands, we issue a new key and the old one stops working at once.

Testing before launch

Send us the staging domain along with the production one, or localhost:<port> for a developer machine. Both can sit on the same key.

Checklist

  1. Client sends name, domains, logo, colour, booking e-mail, the apps they bought (AI mode on or off) and the limit for each app (and optionally webhook, page paths).
  2. We create the key and send the snippets.
  3. Client adds embed.js and the containers, first on staging.
  4. Client checks: search → tracking, rates → details → booking (a test e-mail arrives), a shared link opens on their site.
  5. Client adds the production domain (if not on the list yet) and goes live.