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
| Item | Example | Required |
|---|---|---|
| Company name as visitors should see it | Acme Logistics | Yes |
| Domains the apps will run on (production, staging) | acme.com, www.acme.com, staging.acme.com | Yes |
| Logo, SVG or PNG, square mark or horizontal | https://acme.com/logo.svg | Yes |
| Brand colour, one hex value | #0e6ba8 | Yes |
| More colours, fonts, corner rounding | see Look and feel | Optional |
| E-mail that receives booking requests | [email protected] | For freight rates |
| Webhook URL for booking requests (to their CRM/TMS) | https://acme.com/hooks/navo | Optional |
| Which apps they want | any of tracking, freight, schedules; AI mode on or off | Yes |
| Usage limit per app, per month or per year | tracking: 300 unique shipments a month | Yes |
| Pages where each app will live | /track, /rates, /schedules | Recommended |
| ”Powered by Navo” shown or hidden | shown | Default: 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:
| Package | Apps on the key | What the visitor gets |
|---|---|---|
| Tracking only | tracking | Tracking page; the search widget shows the tracking box with no tabs and no AI switch |
| Tracking with AI | tracking, ai | The same, plus AI mode in the search widget and “Ask AI” on shipments |
| Rates and schedules | freight, schedules | Two tabs in the search widget, the rates and schedules pages |
| Everything | tracking, freight, schedules, ai | All 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:
| App | One unit | Free repeats |
|---|---|---|
tracking | One unique shipment (container, booking or B/L number) | The same shipment for the rest of the period |
freight | One rate search (lane and equipment) | The same search the same day |
schedules | One schedule search (route, vessel or port) | The same search the same day |
ai | One question to the AI assistant | None |
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
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
| Attribute | What it does |
|---|---|
data-navo-app | search, tracking, freight or schedules. |
data-navo-key | The site’s embed key. |
data-navo-pages | JSON map of app to page path on the client’s site. |
data-navo-height | Fixed height in px. By default the frame grows with its content; tracking is a full map, 680 px. |
data-navo-sync | off keeps the page URL unchanged while the visitor clicks around. |
data-navo-theme | JSON 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.
| Key | What it changes | Example |
|---|---|---|
accent | Buttons, links, active tabs, switches, focus ring. Hover and pressed shades are computed from it | "#e4002b" |
text | Main text | "#1a1a1a" |
muted | Secondary text: labels, hints, dates | "#6b7280" |
heading | Headings, carrier and vessel names, map lines | "#002f6c" |
background | Page background of the app. "transparent" lets the client’s own background show through | "#ffffff" |
surface | Cards, inputs, table stripes | "#f5f7fa" |
border | Borders and dividers | "#e5e7eb" |
success | Positive states: delivered, on time, cheapest | "#15803d" |
radius | Corner rounding of cards and panels, px (0 to 40) | 8 |
buttonRadius | Corner rounding of buttons and inputs, px. 999 gives pills (the default) | 6 |
font | Body font. Any Google Fonts family is loaded for you; "system" uses the visitor’s system font | "Inter" |
headingFont | Font for headings, same rules. Without it headings use font | "Playfair Display" |
fontCss | Your 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:
- 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.
- 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.
Links to one exact view
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:
| View | Link |
|---|---|
| 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 rate | the search above plus %26quote%3D<id> |
| Sailing search on a lane | /schedules?navo_app=schedules&navo=origin%3DCNSHA%26destination%3DDEHAM |
| One sailing | the 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
- 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).
- We create the key and send the snippets.
- Client adds
embed.jsand the containers, first on staging. - Client checks: search → tracking, rates → details → booking (a test e-mail arrives), a shared link opens on their site.
- Client adds the production domain (if not on the list yet) and goes live.