SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Read the visitor's browser and device

Parse the User-Agent header once per request, pick a download for the visitor's system, ask platform questions from any view, name devices in a session list and keep bots out of your counts.

After reading this guide, you will know

  • How to parse the User-Agent header once per request and read it as ctx.userAgent
  • How to ask platform questions such as isTouch() from any view without passing the context
  • How to name a device in a session list and keep crawlers out of your counts

A download page that offers the Mac build to a Mac, a hint that prints ⌘K or Ctrl K, a session list that says "Pixel 8" instead of a 150-character string, a page-view counter that skips crawlers: each needs the same few answers from the User-Agent header. This guide reads them with @sdxc/user-agent: parse turns the header into a browser, engine, operating system and device, the middleware does it once per request, and the helpers answer yes-or-no questions from anywhere in the render.

Terminal
npm add @sdxc/user-agent remix

Parse it once per request

Add asyncContext() and userAgent() to the router's global chain. userAgent() parses the header and publishes the result as ctx.userAgent; asyncContext() lets the helpers find the current request on their own later.

app/initializers/router.tsx
import { userAgent } from "@sdxc/user-agent/middleware";
import { asyncContext } from "remix/middleware/async-context";
import { createRouter } from "remix/router";

import defaultHandler from "~/app/http/controllers/default-handler";

export function createWebRouter() {
	let router = createRouter({
		middleware: [
			asyncContext(),
			userAgent(),
			// …then cop(), formData() and a renderer
		],
		defaultHandler,
	});
	// router.map(…) for each route
	return router;
}

Importing the middleware module types ctx.userAgent on every request context. It always has the same shape: every field is a string or null, and a part the header does not reveal reads as null. An empty header and a crawler come back as all-null, so code reading a field decides what null means rather than guarding against a missing object.

Offer the visitor's own download

A download page leads with the build for the visitor's system and lists every build below it. os.name is a union of every system the parser knows, so a switch over it is checked by the compiler, and default covers the null case where the page leads with nothing.

app/http/controllers/download.tsx
import type { OperatingSystemName } from "@sdxc/user-agent";

import { createAction } from "remix/router";

import { DownloadPage } from "~/resources/views/download";
import routes from "~/routes/web";

type Build = "app-store" | "play-store" | "macos-dmg" | "windows-exe";

function buildFor(os: OperatingSystemName | null): Build | null {
	switch (os) {
		case "iOS":
		case "iPadOS":
			return "app-store";
		case "Android":
			return "play-store";
		case "macOS":
			return "macos-dmg";
		case "Windows":
			return "windows-exe";
		default:
			return null;
	}
}

export default createAction(routes.download, (ctx) => {
	let suggested = buildFor(ctx.userAgent.os.name);
	return ctx.render(<DownloadPage suggested={suggested} />);
});

Ask from any view

@sdxc/user-agent/helpers answers the questions a view asks most: isApplePlatform(), isAndroid(), isMobile(), isTablet(), isDesktop() and isTouch(). Called with no argument, each reads the current request through asyncContext(), so a component deep in the page asks without the context being passed down to it:

resources/components/search-hint.tsx
import type { Handle } from "remix/component";

import { isApplePlatform, isTouch } from "@sdxc/user-agent/helpers";

export function SearchHint(_handle: Handle) {
	return () => {
		if (isTouch()) return null;
		return <kbd>{isApplePlatform() ? "⌘K" : "Ctrl K"}</kbd>;
	};
}

Outside a request, in a test or a job, a helper reads the all-null shape and answers false. The same helpers take a request context (isTouch(ctx)) or a parsed value (isTouch(parse(header))) when you have one at hand.

Name the device in a session list

A list of signed-in sessions, or the email that says "a new sign-in from…", needs one short line a person recognizes. Use the device model when the header names one, otherwise the browser and the system together:

app/services/sessions/describe-device.ts
import type { UserAgent } from "@sdxc/user-agent";

export function describeDevice({ browser, os, device }: UserAgent): string {
	if (device.model) return device.model;
	if (browser.name && os.name) return `${browser.name} on ${os.name}`;
	return browser.name ?? os.name ?? "Unknown device";
}

Call it when the session is created and store the line beside it, along with ctx.userAgent.raw if you want the original header for support. A phone running Chrome reads as "Pixel 8", a Mac running Safari as "Safari on macOS".

Versions stay in the spelling the header uses, and some are frozen by the browser itself: Windows 11 reports "10", and Safari reports every macOS release as 10.15.7. Show a version only where an approximate one is useful.

Keep bots out of your counts

Crawlers, link-preview fetchers, uptime monitors and HTTP libraries parse as all-null, which looks like an unknown browser. isBot names them, so a page-view counter or a session store skips them:

app/http/middleware/count-views.ts
import type { Middleware } from "remix/router";

import { isBot } from "@sdxc/user-agent";

import { PageViews } from "~/app/models/page-views";

export default function countViews(): Middleware {
	return async (ctx, next) => {
		if (!isBot(ctx.userAgent.raw)) {
			await PageViews.record(ctx.db, {
				path: ctx.url.pathname,
				browser: ctx.userAgent.browser.name,
				device: ctx.userAgent.device.type,
			});
		}
		return next();
	};
}

An empty header counts as a bot, since every browser sends one.

Test with the header a browser sends

The router parses whatever User-Agent the request carries, so one request per platform drives each branch:

app/http/controllers/download.test.ts
import { expect, test } from "vitest";

import { createWebRouter } from "~/app/initializers/router";
import routes from "~/routes/web";

const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15";
const PIXEL =
	"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36";

async function downloadPage(userAgent: string) {
	let url = new URL(routes.download.href(), "https://example.com");
	let response = await createWebRouter().fetch(
		new Request(url, { headers: { "User-Agent": userAgent } }),
	);
	return response.text();
}

test("leads with the visitor's own build", async () => {
	expect(await downloadPage(MAC)).toContain("macos-dmg");
	expect(await downloadPage(PIXEL)).toContain("play-store");
});

toContain checks whatever DownloadPage prints for the suggested build; match it to your view's markup.

Run it from the terminal

Read the User-Agent column of an access log to see which systems your visitors run before you order the download buttons:

Terminal
sdxc pkg user-agent parse "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15"
cut -f5 access.log | sdxc pkg user-agent parse
sdxc pkg user-agent parse --json < user-agents.txt

parse prints browser, engine, operating system and device tab-separated, one line per string, with - for a part the string leaves out. Use a package from the terminal covers the conventions every package command line shares.

Where to go next

Documentation

Can’t find it? Contact us