Build guides
Build a fleet management app
Last updated:
Build Northgate Freight's fleet desk on Next.js and better-auth: a roster built from a pasted list of VINs in one batch request, classed by weight, valued and listed with the recalls issued for each model and year. See the use case.

You'll build Northgate Freight, a freight carrier's app: paste the yard's VINs and get every unit classed by weight and valued. See what it does.
- Time: About an hour, or 5 minutes to clone and run.
- You need: Node 20 or later and pnpm, A Cardog API key, Some Next.js
- Cardog SDK:
cardog.v2.vin.batch,cardog.v2.specs.sheetByBuild,cardog.v2.quotes.getMany,cardog.v2.recalls.entity - Clone the finished app: cardog-ai/example-fleet-management, or build it step by step below.
git clone https://github.com/cardog-ai/example-fleet-management.git1. Get an API key
Every request to Cardog carries a key. Create one under Account → API, then copy .env.example to .env.local. The app reads the key on the server; the browser never sees it.
The example values run as shipped. Without a key the app runs on responses recorded for fourteen VINs, so you can follow along before you sign up.
BETTER_AUTH_SECRET ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (openssl rand -base64 32) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.
.env.example
# Copy to .env.local. With these values the app runs on recorded fixtures
# and a development auth secret.
# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=
# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=northgate-dev-only-secret-replace-before-deploying
# Where the app is served. Needed when it is not http://localhost:3023.
BETTER_AUTH_URL=http://localhost:3023
# SQLite file. Created on first run.
DATABASE_PATH=data/northgate.db
# Seeded review logins (created by `pnpm db:setup` on an empty database):
# fleet manager manager@northgate.example / northgate-manager
# driver driver@northgate.example / northgate-driver2. Create the Next.js app
Northgate's desk is one Next.js app: the manager's roster, the driver's page and the server code that talks to Cardog. SQLite keeps it to one process with nothing else to run. better-sqlite3 is a native module, so the config loads it at runtime.
terminal
pnpm create next-app@latest northgate --ts --app --tailwind --src-dir
cd northgate
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// better-sqlite3 is a native module: load it from node_modules at runtime.
serverExternalPackages: ["better-sqlite3"],
// This app is its own root, even when it sits inside another repository.
outputFileTracingRoot: process.cwd(),
// The rail's foot is where Next's dev badge sits; keep it clear.
devIndicators: false,
};
export default nextConfig;3. Add managers and drivers
Everyone signs in with email and password. role is a user field sign-up cannot set, so anyone who signs up is a driver; the seed makes the first fleet manager, and managers can make others. Every manager page and action checks the role itself, and a driver lands on their own unit.
authSecret() picks the secret that signs sessions: yours whenever BETTER_AUTH_SECRET is set, and the development placeholder only on your machine and during next build. A production server with no secret stops, and one still on the placeholder logs a warning.
src/lib/auth.ts
function authSecret(): string {
const set = process.env.BETTER_AUTH_SECRET?.trim();
const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
if (set) {
if (production && set === DEV_SECRET) {
console.warn("[northgate] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
}
return set;
}
if (!production) return DEV_SECRET;
throw new Error("[northgate] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}src/lib/auth.ts
export const auth = betterAuth({
appName: "Northgate Fleet",
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3023",
secret: authSecret(),
database: drizzleAdapter(db, {
provider: "sqlite",
schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
}),
emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
user: {
additionalFields: {
role: { type: "string", required: false, defaultValue: "driver", input: false },
},
},
plugins: [nextCookies()],
});src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);src/lib/session.ts
export async function requireManager(): Promise<Viewer> {
const v = await requireViewer();
if (v.role !== "manager") redirect("/my-unit");
return v;
}4. Construct the Cardog client on the server
One file holds every Cardog call. The client is built once from the key, and the server-only import fails the build if a browser bundle ever reaches for it. Each call is timed and logged, which is what the app's "What the app called" pane shows.
SDK call: new CardogClient({ apiKey, appId })
src/lib/cardog.ts
const apiKey = process.env.CARDOG_API_KEY?.trim();
/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
? new CardogClient({ apiKey, appId: "northgate-fleet", baseUrl: process.env.CARDOG_BASE_URL || undefined })
: null;
export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };
// The v2 response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinBatch = Awaited<ReturnType<V2["vin"]["batch"]>>;
export type VinBatchItem = VinBatch["results"][number];
export type VinIdentity = Extract<VinBatchItem, { ok: true }>["identity"];
export type BuildSpecSheet = Awaited<ReturnType<V2["specs"]["sheetByBuild"]>>;
export type MultiQuote = Awaited<ReturnType<V2["quotes"]["getMany"]>>;
export type Quote = MultiQuote["quotes"][number];
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export type Recall = ModelYearRecalls["recalls"][number];
export interface Result<T> {
data: T | null;
call: CallRecord;
error: string | null;
}5. Decode a whole yard in one request
Managers paste VINs one per line, or rows straight from a spreadsheet with the unit number beside the VIN. The parser finds the VIN in each row and checks it before any call; malformed VINs are skipped. The rest go to Cardog in one batch, and one bad VIN fails its own row, never the batch.
Then read one spec sheet per distinct build code. Twenty identical vans cost one decode request and one spec read.
SDK call: cardog.v2.vin.batch(vins)
What comes back. One row for each VIN, in request order: { ok: true, identity } with year, make, model, the build code and refs such as refs.gvwr, or { ok: false, error }.
src/lib/vin.ts
export function parsePaste(text: string): PastedRow[] {
const rows: PastedRow[] = [];
const seen = new Set<string>();
text.split(/\r?\n/).forEach((raw, i) => {
const fields = raw
.split(/[,\t;]/)
.map((f) => f.trim().replace(/^"|"$/g, ""))
.filter(Boolean);
if (!fields.length) return;
const norm = fields.map(normalizeVin);
let at = norm.findIndex((f) => f.length === 17);
if (at < 0) {
// No 17-character field: take the longest that looks like an attempt.
const longest = norm.reduce((b, f, j) => (f.length > (norm[b]?.length ?? 0) ? j : b), 0);
if (norm[longest].length < 11 || !/\d/.test(norm[longest])) return; // header or note
at = longest;
}
const vin = norm[at];
const other = fields.find((_, j) => j !== at && fields[j].length <= 12) ?? null;
rows.push({
line: i + 1,
vin,
number: other ? other.toUpperCase() : null,
problem: vinProblem(vin),
duplicate: seen.has(vin),
});
seen.add(vin);
});
return rows;
}src/lib/cardog.ts
export function decodeBatch(vins: string[], step: string) {
return run<VinBatch>({
step,
method: "cardog.v2.vin.batch",
args: [vins.length > 3 ? [...vins.slice(0, 3), `… ${vins.length - 3} more`] : vins],
http: `POST /v2/vin/batch { vins: [${vins.length}] }`,
returns: "One identity (or one error) per VIN, in request order, plus requested/decoded/failed counts",
live: (c) => c.v2.vin.batch(vins),
fixture: () => fixtureBatch(vins),
});
}src/lib/cardog.ts
export function readBuildSpecs(nano: string, step: string) {
return run<BuildSpecSheet>({
step,
method: "cardog.v2.specs.sheetByBuild",
args: [nano],
http: `GET /v2/specs/${nano}`,
returns: "Spec sheet for the build, by section, with the ids that vary by trim",
live: (c) => c.v2.specs.sheetByBuild(nano),
fixture: () => fixtureSpecs(nano),
});
}src/lib/unit-read.ts
export async function readUnits(vins: string[], step: string): Promise<UnitRead> {
const calls: CallRecord[] = [];
const decode = await decodeBatch(vins.slice(0, BATCH_MAX), step);
calls.push(decode.call);
if (!decode.data) {
return {
rows: vins.map((vin) => ({ vin, ok: false, error: decode.error ?? "The decode failed.", code: null })),
calls,
callsByVin: {},
};
}
// One spec read per build code, a few at a time.
const items = decode.data.results;
const nanos = [
...new Set(items.flatMap((r) => (r.ok && r.identity.valid && r.identity.nano ? [r.identity.nano] : []))),
];
const sheets = new Map<string, Record<string, unknown> | null>();
const sheetCalls = new Map<string, CallRecord>();
for (let i = 0; i < nanos.length; i += 6) {
await Promise.all(
nanos.slice(i, i + 6).map(async (nano) => {
const s = await readBuildSpecs(nano, step);
sheets.set(nano, (s.data as Record<string, unknown> | null) ?? null);
sheetCalls.set(nano, s.call);
calls.push(s.call);
})
);
}
const callsByVin: Record<string, CallRecord[]> = {};
const rows: ReadRow[] = items.map((r) => {
if (!r.ok) return { vin: r.vin, ok: false, error: r.error.message, code: r.error.code };
const id = r.identity;
if (!id.valid) {
const why = id.reasons?.length ? id.reasons.join(", ") : "no reason given";
return { vin: r.vin, ok: false, error: `Did not decode (${why}).`, code: "invalid" };
}
const sheet = id.nano ? (sheets.get(id.nano) ?? null) : null;
callsByVin[r.vin] = [decode.call, ...(id.nano && sheetCalls.get(id.nano) ? [sheetCalls.get(id.nano)!] : [])];
return { vin: r.vin, ok: true, facts: buildUnitFacts(r.vin, id as unknown as Record<string, unknown>, sheet) };
});
return { rows, calls, callsByVin };
}6. Class each unit by weight
The decode's gvwr ref names the weight class and its range, so the class is read straight off it: Class 2H is 9,001 to 10,000 lb and light duty, Class 8 is heavy. The preview shows what each VIN is with a suggested unit number, and confirming saves exactly what was shown.
src/lib/unit-facts.ts
export function weightClass(ref: string | null | undefined): WeightClass | null {
const m = ref?.match(/^gvwr:class-(\d)([a-z]?)-(.+)$/);
if (!m) return null;
const [, digit, letter, rest] = m;
const lbPart = rest.split("-lb")[0];
const nums: string[] = [];
for (const t of lbPart.split("-")) {
if (t.length === 3 && nums.length) nums[nums.length - 1] += "," + t;
else nums.push(t);
}
const range = nums.length >= 2 ? `${nums[0]} – ${nums[1]} lb` : `${nums[0]} lb${rest.includes("and-above") ? " and up" : ""}`;
const n = Number(digit);
return {
ref: ref!,
code: `${digit}${letter.toUpperCase()}`,
range,
duty: n <= 2 ? "Light duty" : n <= 6 ? "Medium duty" : "Heavy duty",
order: n + (letter ? (letter.charCodeAt(0) - 96) / 10 : 0),
};
}src/app/actions/units.ts
export async function previewUnits(text: string): Promise<Preview> {
const me = await requireManager();
const pasted = parsePaste(text).slice(0, BATCH_MAX);
const onRoster = vinsOnRoster();
const skip = (p: (typeof pasted)[number]) =>
p.problem?.level === "stop"
? p.problem.message
: p.duplicate
? `Repeats line ${pasted.find((q) => q.vin === p.vin)!.line}.`
: onRoster.has(p.vin)
? "Already on the roster."
: null;
const toSend = pasted.filter((p) => !skip(p));
const read = toSend.length ? await readUnits(toSend.map((p) => p.vin), "Add units") : null;
if (read) holdReads(me.id, read);
const byVin = new Map(read?.rows.map((r) => [r.vin, r]));
const taken = takenNumbers();
const given = new Set(toSend.map((p) => p.number).filter((n): n is string => !!n && !taken.has(n)));
const needNumber = toSend.filter((p) => !p.number || !given.has(p.number));
const suggested = suggestNumbers(
needNumber.map((p) => {
const r = byVin.get(p.vin);
return r?.ok ? r.facts.body : null;
}),
new Set([...taken, ...given])
);
const suggestion = new Map(needNumber.map((p, i) => [p.vin, suggested[i]]));
const rows: PreviewRow[] = pasted.map((p) => {
const reason = skip(p);
const base = {
line: p.line,
vin: p.vin,
warning: p.problem?.level === "warn" ? p.problem.message : null,
title: null,
classCode: null,
classRange: null,
powertrain: null,
number: null,
};
if (reason) return { ...base, state: "skipped", reason, warning: null };
const r = byVin.get(p.vin);
if (!r || !r.ok) return { ...base, state: "failed", reason: r && !r.ok ? r.error : "No answer for this VIN." };
return {
...base,
state: "ready",
reason: null,
number: p.number && given.has(p.number) ? p.number : (suggestion.get(p.vin) ?? null),
title: unitTitle(r.facts),
classCode: r.facts.weightClass?.code ?? null,
classRange: r.facts.weightClass?.range ?? null,
powertrain: r.facts.powertrain,
};
});
return { rows, calls: read?.calls ?? [] };
}
7. Value and recalls, once per model year
Value and recalls belong to a model year, not a unit. One multi-quote prices up to twenty model years, one recall list is read for each, and both are stored on the model year and shared by every unit of it. Adding units reads only the model years that are new; Refresh value and recalls reads them all again.
The quote is for the model year, all trims and mileages. Recalls are matched by model and year, and the app titles them "Recalls issued for this model and year" everywhere.
SDK call: cardog.v2.quotes.getMany(modelYearRefs)
What comes back. One quote per model year in request order: priceMedian, priceP25, priceP75, the live count and asOf.
src/lib/cardog.ts
export function readQuotes(refs: string[], step: string) {
return run<MultiQuote>({
step,
method: "cardog.v2.quotes.getMany",
args: [refs],
http: `GET /v2/quotes?refs=${refs.length} refs`,
returns: "One quote per model year: live count, p25, median, p75, asOf",
live: (c) => c.v2.quotes.getMany(refs),
fixture: () => fixtureQuotes(refs),
});
}src/lib/cardog.ts
export function readModelYearRecalls(modelYearRef: string, step: string) {
return run<ModelYearRecalls>({
step,
method: "cardog.v2.recalls.entity",
args: [modelYearRef, { limit: 25 }],
http: `GET /v2/recalls/entity/${modelYearRef}?limit=25`,
returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA), with the total",
live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 25 }),
fixture: () => fixtureRecalls(modelYearRef),
});
}src/lib/model-years.ts
export async function refreshModelYears(refs: { ref: string; label: string }[], step: string) {
const calls: CallRecord[] = [];
const unique = [...new Map(refs.map((r) => [r.ref, r])).values()];
if (!unique.length) return calls;
const quotes = new Map<string, Quote>();
for (let i = 0; i < unique.length; i += QUOTES_MAX) {
const chunk = unique.slice(i, i + QUOTES_MAX).map((r) => r.ref);
const q = await readQuotes(chunk, step);
calls.push(q.call);
q.data?.quotes.forEach((quote) => quotes.set(quote.ref, quote));
}
const recalls = new Map<string, ModelYearRecalls>();
for (let i = 0; i < unique.length; i += 4) {
await Promise.all(
unique.slice(i, i + 4).map(async ({ ref }) => {
const r = await readModelYearRecalls(ref, step);
calls.push(r.call);
if (r.data) recalls.set(ref, r.data);
})
);
}
const now = new Date();
for (const { ref, label } of unique) {
const mine = calls.filter((c) => JSON.stringify(c.args).includes(ref));
db.insert(schema.modelYear)
.values({
ref,
label,
quote: quotes.get(ref) ?? null,
recalls: recalls.get(ref) ?? null,
calls: mine,
readAt: now,
})
.onConflictDoUpdate({
target: schema.modelYear.ref,
set: {
label,
quote: quotes.get(ref) ?? null,
recalls: recalls.get(ref) ?? null,
calls: mine,
readAt: now,
updatedAt: now,
},
})
.run();
}
return calls;
}src/app/actions/units.ts
export async function addUnits(items: { vin: string; number: string }[]): Promise<AddResult> {
const me = await requireManager();
const errors: string[] = [];
const calls: CallRecord[] = [];
const taken = takenNumbers();
const onRoster = vinsOnRoster();
const seen = new Set<string>();
// Anything not held from the preview (an hour has passed, or the server restarted) is read again.
const missing = items.filter((i) => !heldRead(me.id, i.vin)).map((i) => i.vin);
if (missing.length) {
const read = await readUnits(missing, "Add units");
holdReads(me.id, read);
calls.push(...read.calls);
}
const added: AddResult["added"] = [];
const years: { ref: string; label: string }[] = [];
const now = new Date();
for (const { vin, number: raw } of items) {
const number = raw.trim().toUpperCase();
if (!number) errors.push(`${vin}: give it a unit number.`);
else if (taken.has(number) || seen.has(number)) errors.push(`${vin}: ${number} is already in use.`);
else if (onRoster.has(vin)) errors.push(`${vin}: already on the roster.`);
else {
const h = heldRead(me.id, vin);
if (!h) {
errors.push(`${vin}: could not be read.`);
continue;
}
const f: UnitFacts = h.facts;
const id = crypto.randomUUID();
db.insert(schema.unit)
.values({
id,
number,
vin,
year: f.year,
make: f.make,
model: f.model,
trim: f.trim,
modelYearRef: f.modelYearRef,
nano: f.nano,
facts: f,
calls: h.calls,
refreshedAt: now,
})
.run();
seen.add(number);
added.push({ id, number, vin });
if (f.modelYearRef) years.push({ ref: f.modelYearRef, label: unitTitle(f) });
}
}
// Value and recalls belong to the model year: one multi-quote, one recall list each.
calls.push(...(await refreshModelYears(years, "Value and recalls")));
revalidatePath("/", "layout");
return { added, errors, calls };
}src/app/actions/fleet.ts
export async function refreshFleet(): Promise<{ calls: CallRecord[] }> {
await requireManager();
const units = db
.select({ ref: schema.unit.modelYearRef, facts: schema.unit.facts })
.from(schema.unit)
.where(isNotNull(schema.unit.modelYearRef))
.all();
const calls = await refreshModelYears(
units.map((u) => ({ ref: u.ref!, label: unitTitle(u.facts as UnitFacts) })),
"Refresh value and recalls"
);
revalidatePath("/", "layout");
return { calls };
}
8. Open a unit
A unit's page shows its spec read, its value and the recalls issued for its model and year, with its number, status, note and driver. Read again re-decodes the unit and re-reads its model year. Where the spec sheet serves no figure, or a figure varies by trim, the page says "not listed" or "varies by trim" rather than a number.
SDK call: cardog.v2.recalls.entity(modelYearRef)
What comes back. Transport Canada and NHTSA campaigns issued for the model and year, with the total.
src/app/actions/units.ts
export async function refreshUnit(id: string): Promise<{ calls: CallRecord[]; error: string | null }> {
await requireManager();
const u = db.select().from(schema.unit).where(eq(schema.unit.id, id)).get();
if (!u) return { calls: [], error: "No such unit." };
const read = await readUnits([u.vin], "Refresh unit");
const row = read.rows[0];
const calls = [...read.calls];
if (!row?.ok) return { calls, error: row && !row.ok ? row.error : "No answer." };
const f = row.facts;
if (f.modelYearRef) calls.push(...(await refreshModelYears([{ ref: f.modelYearRef, label: unitTitle(f) }], "Refresh unit")));
db.update(schema.unit)
.set({
year: f.year,
make: f.make,
model: f.model,
trim: f.trim,
modelYearRef: f.modelYearRef,
nano: f.nano,
facts: f,
calls,
refreshedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(schema.unit.id, id))
.run();
revalidatePath(`/units/${id}`);
revalidatePath("/roster");
return { calls, error: null };
}
9. Assign drivers
Managers give a unit to a driver from either side, and a driver holds one unit at a time. A driver signs in to one page: their unit's number, VIN, spec and the recalls issued for its model and year, read from what the desk stored, with no new call.
src/app/actions/drivers.ts
export async function assignDriver(unitId: string, driverId: string | null): Promise<{ error: string | null }> {
await requireManager();
if (driverId) {
const d = db.select().from(schema.user).where(eq(schema.user.id, driverId)).get();
if (!d || d.role !== "driver") return { error: "No such driver." };
db.update(schema.unit)
.set({ driverId: null, updatedAt: new Date() })
.where(and(eq(schema.unit.driverId, driverId), ne(schema.unit.id, unitId)))
.run();
}
db.update(schema.unit).set({ driverId, updatedAt: new Date() }).where(eq(schema.unit.id, unitId)).run();
revalidatePath("/", "layout");
return { error: null };
}src/lib/fleet.ts
export function unitForDriver(driverId: string) {
const row = db.select({ id: schema.unit.id }).from(schema.unit).where(eq(schema.unit.driverId, driverId)).get();
return row ? getUnit(row.id) : null;
}
10. Deploy
Northgate ships as one container with SQLite on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database; the Cardog code does not change.
Dockerfile
# Northgate fleet desk: one container, SQLite on a mounted volume.
# docker build -t northgate-fleet .
# docker run -p 3023:3023 -v northgate-data:/app/data \
# -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host northgate-fleet
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3023
CMD ["pnpm", "start"]terminal
docker build -t northgate .
docker run -p 3023:3023 -v northgate-data:/app/data \
-e CARDOG_API_KEY=your_cardog_api_key \
-e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
-e BETTER_AUTH_URL=https://fleet.example.com \
northgateNext steps
- VIN decode: Every field the decode returns, and batch decoding.
- Recalls: Campaigns issued for a model and year.
- Feeds: Re-read the roster on a schedule.
Was this helpful?