
Adding Umami Cloud Analytics on your NextJS Website
Introduction
Umami is a lightweight, open-source web analytics platform that helps you track your website's traffic while respecting user privacy in compliance with GDPR principles.
In this article, I am going to show you how to integrate Umami Cloud analytics, a hosted version of the Umami application, onto your NextJS project. We'll add a tracking script to your project and then use Umami API to access the analytics data.
You can sign up for free at https://cloud.umami.is/signup.
Key features:
- Core analytics — Pageviews, visitors, bounce rate, session duration, referrers, browsers, operating systems, devices, and countries.
- Custom events — Track button clicks, form submissions, or any user interaction with a simple data attribute or JavaScript call.
- Advanced insights — Funnels, user journeys, retention analysis, goals, UTM campaign tracking, and cohort breakdowns.
- Sessions — View individual visitor activity and session properties without identifying personal information.
- API — Full REST API for programmatic access to all your analytics data.
For more information not covered in this article, head over to Umami Cloud Documentation.
Step 1: Set up Umami
You have two options:
Option A: Use hosted Umami (this is the one we'll use in this article)
- Go to https://cloud.umami.is/signup
- Create an account
- Add your website
- Copy your Website ID and script URL
Option B: Self-host Umami
You can deploy it using:
- Docker
- Vercel
- Railway
Official repo:
https://github.com/umami-software/umami
Step 2: Add Umami to your Next.js app
Add the script to your root layout.
Using app/layout.tsx (App Router)
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<script
defer
src="https://cloud.umami.is/script.js"
data-website-id="your-website-id"
data-auto-track="true"
strategy="afterInteractive"
/>
</head>
<body>{children}</body>
</html>
);
}
Using pages/app.tsx (Pages Router)
import Script from "next/script";
import type { AppProps } from "next/app";
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<Script
async
src="https://cloud.umami.is/script.js"
data-website-id="your-website-id"
data-auto-track="true"
strategy="afterInteractive"
/>
</>
);
}
At this point, Umami can now track your website's traffic. You can navigate a few pages and then go to https://cloud.umami.is, click on your website and see the data collected on your site.
Step 3: Getting started with Umami Cloud API
Umami provides endpoints like:
/api/websites/api/websites/{websiteId}/stats/api/websites/{websiteId}/sessions/api/websites/{websiteId}/metrics
We will mainly use /stats and /sessions in this tutorial.
For Authentication, Umami uses API tokens. You need to
- Go to Umami Dashboard
- Settings >> API Keys
- Generate a key
You'll now need to get your API key and Website ID from Umami and set them into your .env.local file as follows:
UMAMI_API_KEY=<your-api-key>
UMAMI_WEBSITE_ID=<your-website-id>
UMAMI_API_URL=https://api.umami.is/v1
Step 4: Setting up Umami helper and displaying our data on NextJS
We will now create an external helper in NextJS to connect our app to Umami.
// lib/umami.ts
const UMAMI_BASE_URL = process.env.UMAMI_API_URL!;
const UMAMI_API_KEY = process.env.UMAMI_API_KEY!;
type UmamiStats = {
pageviews: number;
visitors: number;
visits: number;
bounces: number;
totaltime: number;
comparison?: Partial<UmamiStats>;
};
type SessionItem = {
id: string;
firstAt: number;
lastAt: number;
pageviews: number;
device?: string;
browser?: string;
os?: string;
country?: string;
city?: string;
referrer?: string;
createdAt: number;
visits: number;
views: number;
};
async function umamiFetch<T>(endpoint: string): Promise<T> {
const res = await fetch(`${UMAMI_BASE_URL}${endpoint}`, {
headers: {
Authorization: `Bearer ${UMAMI_API_KEY}`,
"Content-Type": "application/json",
},
cache: "no-store", // important for fresh analytics
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Umami API error (${res.status}): ${errorText}`);
}
return res.json();
}
function buildParams(params: Record<string, string | number | undefined>) {
return new URLSearchParams(
Object.entries(params).reduce(
(acc, [k, v]) => {
if (v !== undefined) acc[k] = String(v);
return acc;
},
{} as Record<string, string>,
),
).toString();
}
// ---- API functions ----
export function getWebsiteStats(
websiteId: string,
startAt: number,
endAt: number,
) {
return umamiFetch<UmamiStats>(
`/websites/${websiteId}/stats?${buildParams({ startAt, endAt })}`,
);
}
export async function getSessions(
websiteId: string,
startAt: number,
endAt: number,
page: number = 1,
pageSize: number = 20,
): Promise<{
data: SessionItem[];
pagination: { total: number; totalPages: number };
}> {
const res = await umamiFetch<any>(
`/websites/${websiteId}/sessions?${buildParams({
startAt,
endAt,
page,
pageSize,
timezone: "Africa/Nairobi",
})}`,
);
return {
data: res.data || res,
pagination: res.pagination || { total: res.length, totalPages: 1 },
};
}
We will then create a simple page that fetches this data from our helper, to display on our page. This is an example of a simple dashboard that we'll use to view the stats on our site.
import { getWebsiteStats, getSessions } from "@/lib/umami";
import Link from "next/link";
export default async function AnalyticsPage({
searchParams,
}: {
searchParams: Promise<{ range?: string }>;
}) {
const websiteId = process.env.UMAMI_WEBSITE_ID!;
const [stats, sessionsData] = await Promise.all([
getWebsiteStats(websiteId, startAt, endAt),
getSessions(websiteId, startAt, endAt, 1, 50),
]);
const sessions = sessionsData.data || sessionsData;
const sortedSessions = [...sessions]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 30);
function formatTime(seconds: number) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
let result = "";
if (hrs > 0) result += `${hrs}h `;
if (mins > 0 || hrs > 0) result += `${mins}m `;
result += `${secs}s`;
return result.trim();
}
return (
<div className="bg-black text-white min-h-screen px-4 py-6 mt-16">
{/* Header */}
<div className="flex flex-wrap justify-between items-center gap-2 mb-6">
<h1 className="text-xl font-semibold">Analytics</h1>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<div className="border border-gray-800 rounded p-3">
<div className="text-xs text-gray-400">Visits</div>
<div className="text-2xl font-semibold">{stats.visits}</div>
</div>
<div className="border border-gray-800 rounded p-3">
<div className="text-xs text-gray-400">Unique visitors</div>
<div className="text-2xl font-semibold">{stats.visitors}</div>
</div>
<div className="border border-gray-800 rounded p-3">
<div className="text-xs text-gray-400">Pageviews</div>
<div className="text-2xl font-semibold">{stats.pageviews}</div>
</div>
<div className="border border-gray-800 rounded p-3">
<div className="text-xs text-gray-400">Bounce rate</div>
<div className="text-2xl font-semibold">
{stats.visits ? ((stats.bounces / stats.visits) * 100).toFixed(1) : 0}%
</div>
</div>
</div>
{/* Sessions table */}
<div>
<h2 className="text-sm font-medium text-gray-400 mb-2">Recent sessions</h2>
<div className="border border-gray-800 rounded overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-gray-400 border-b border-gray-800">
<tr>
<th className="text-left p-2">Visitor</th>
<th className="text-left p-2">Device</th>
<th className="text-left p-2">Browser</th>
<th className="text-left p-2">OS</th>
<th className="text-left p-2">Country</th>
<th className="text-left p-2">Duration</th>
<th className="text-left p-2">Last activity</th>
</tr>
</thead>
<tbody>
{sortedSessions.map((session) => (
<tr key={session.id} className="border-b border-gray-800/50">
<td className="p-2 font-mono text-xs">{session.id.slice(0, 6)}…</td>
<td className="p-2">{session.device || "—"}</td>
<td className="p-2">{session.browser || "—"}</td>
<td className="p-2">{session.os || "—"}</td>
<td className="p-2">{session.country || "—"}</td>
<td className="p-2">{formatTime(session.duration)}</td>
<td className="p-2 text-xs">{new Date(session.lastAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
Aftermath
After doing all these steps, you are likely to see the Umami data we fetched on your dashboard. You can compare them to the data on Umami website and see that they are accurate. There are alot more endpoints on the Umami Cloud API that you could use to enhance your analytics and tailor them to your needs. The full documentation can always be found on the official Umami Docs website. This was just a simple setup and you can freely tweak inside your code to learn even more.
Performance impact
Umami is extremely lightweight:
- Script size ~2KB
- Loads asynchronously
- Doesn’t block rendering
This makes it perfect for performance-focused apps.
Testing your setup
After setup:
- Open your site
- Visit a few pages
- Go to your Umami dashboard
- You should see your data
Conclusion
Umami is a powerful alternative to traditional analytics tools, especially if you value privacy, simplicity, and performance.
With just a few lines of code, you can gain meaningful insights into your users without compromising their trust.