Build guides
Check a VIN at the registration counter
Last updated:
Build the Department of Vehicle Services counter on Next.js and better-auth: a scanner-friendly VIN strip, a decode mapped onto the form's codes, a document check and a supervisor queue. See the use case.

You'll build Department of Vehicle Services, a motor vehicle office's app: the clerk scans the VIN and the form fills itself, gaps marked. See what it does.
- Time: About 40 minutes, or 5 to clone and run.
- You need: Node 20 or later and pnpm, A Cardog API key, Some Next.js
- Cardog SDK:
cardog.vin.image,cardog.v2.vin.getByVin,cardog.v2.recalls.entity - Clone the finished app: cardog-ai/example-registration-counter, or build it step by step below.
git clone https://github.com/cardog-ai/example-registration-counter.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 it on the server; the browser never sees it.
The example values run as shipped. Without a key the counter runs on responses recorded for five sample 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=dvs-counter-dev-only-secret-replace-before-deploying
# Where the app is served. Needed when it is not http://localhost:3029.
BETTER_AUTH_URL=http://localhost:3029
# SQLite file. Created on first run.
DATABASE_PATH=data/counter.db
# The seeded review logins are listed in README.md.2. Create the Next.js app
The counter is one Next.js app: the counter screen, the day log, the supervisor queue, and the server code that talks to Cardog. SQLite keeps it to one process. better-sqlite3 is a native module, so the config loads it at runtime.
A counter tool is read across a desk, so the theme is plain: 18px base type, black on white, one action colour and three status colours.
terminal
pnpm create next-app@latest registration-counter --ts --app --tailwind --src-dir
cd registration-counter
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(),
};
export default nextConfig;3. Add staff sign-in
Clerks and supervisors sign in with email and password, and a session lasts one shift. role is a user field sign-up cannot set: everyone who signs up is a clerk, and the seed script makes the supervisor.
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("[dvs-counter] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
}
return set;
}
if (!production) return DEV_SECRET;
throw new Error("[dvs-counter] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}src/lib/auth.ts
export const auth = betterAuth({
appName: "Department of Vehicle Services",
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3029",
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 },
// A counter shift, not a month: sessions last 12 hours.
session: { expiresIn: 60 * 60 * 12, updateAge: 60 * 60 },
user: {
additionalFields: {
role: { type: "string", required: false, defaultValue: "clerk", 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);4. Construct the Cardog client on the server
One file holds every Cardog call, and there are three. The client is built once from the key, and the server-only import fails the build if a browser bundle reaches for it. Each call is timed and logged, which is what the "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: "dvs-registration-counter", baseUrl: process.env.CARDOG_BASE_URL || undefined })
: null;
export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };
// The response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export type VinImageRead = Awaited<ReturnType<CardogClient["vin"]["image"]>>;
export interface Result<T> {
data: T | null;
call: CallRecord;
error: string | null;
}5. Build the VIN strip
Most VINs at a counter come from a barcode scanner, which adds spaces, dashes, Code 39 asterisks and sometimes a leading I. Clean those off first.
Then work out the check digit as the VIN goes in. The decode reads the build, positions 1 to 8, 10 and 11, so a VIN one character off at position 9 can still decode; the counter's own check is what catches it.
src/lib/vin.ts
export function normalizeVin(raw: string) {
let v = raw.toUpperCase().replace(/[\s\-*]/g, "");
if (v.length === 18 && v.startsWith("I")) v = v.slice(1);
return v.slice(0, 17);
}src/lib/vin.ts
export function checkVin(vin: string): VinCheck {
if (!vin) return { state: "empty" };
const bad = vin.search(/[IOQ]/);
if (bad >= 0) {
const c = vin[bad];
const hint = c === "I" ? "1 (one)" : "0 (zero)";
return {
state: "invalid",
at: bad,
message: `Character ${bad + 1} is the letter ${c}. VINs never use ${c}: it is almost always ${hint}.`,
};
}
const other = vin.search(/[^A-HJ-NPR-Z0-9]/);
if (other >= 0) return { state: "invalid", at: other, message: `Character ${other + 1} is not a letter or a number.` };
if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
const expected = expectedCheckDigit(vin);
if (expected && expected !== vin[8]) {
return {
state: "check-mismatch",
expected,
message: `Check digit fails. The 9th character is ${vin[8]}; the other sixteen say it should be ${expected}. Read the VIN from the vehicle again.`,
};
}
return { state: "ok", message: "17 characters. Check digit passes." };
}
6. Read the VIN from a photo
Some labels are too worn for the scanner. The clerk photographs the label or the document instead; the route sends the image from the server and returns the VIN, which then goes through the same clean-up and check as a scanned one.
SDK call: cardog.vin.image(base64)
What comes back. success and result, the VIN read from the photo.
src/lib/cardog.ts
export async function readVinFromImage(base64: string, step: string): Promise<Result<VinImageRead>> {
return run<VinImageRead>({
step,
vin: FIXTURE_VINS[0].vin,
key: "image",
method: "cardog.vin.image",
args: [`<${Math.round((base64.length * 3) / 4 / 1024)} KB image, base64>`],
http: "POST /vin/image",
returns: "The VIN read from the photo",
live: (c) => c.vin.image(base64),
});
}src/app/api/vin/photo/route.ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVinFromImage } from "@/lib/cardog";
import { normalizeVin } from "@/lib/vin";
const MAX_BYTES = 8 * 1024 * 1024;
/** POST multipart { photo } → the VIN read from it. Counter staff only. */
export async function POST(req: Request) {
const me = await getViewer();
if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
const form = await req.formData().catch(() => null);
const file = form?.get("photo");
if (!(file instanceof File)) return NextResponse.json({ ok: false, error: "No photo received." }, { status: 400 });
if (file.size > MAX_BYTES) return NextResponse.json({ ok: false, error: "That photo is over 8 MB." }, { status: 413 });
const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
const r = await readVinFromImage(base64, "Counter · photo");
const vin = r.data?.success ? normalizeVin(r.data.result) : null;
const ok = Boolean(vin && vin.length === 17);
return NextResponse.json({
ok,
vin,
error: ok ? null : (r.error ?? "No VIN found in that photo. Take it closer and straight on, or type the VIN."),
calls: [r.call],
});
}7. Decode the VIN into the form
Send the VIN from a route handler, never from the browser. The decode returns year, make and model as names, and body style, fuel, weight class and vehicle type as entity refs.
Map each ref onto the form's own code list. When the decode leaves a field empty, or returns a value the form has no code for, the field is left for the clerk with the reason in words. Nothing is guessed.
SDK call: cardog.v2.vin.getByVin(vin)
What comes back. Year, make and model, and refs such as bodyStyle, fuelType, electrificationLevel, vehicleType, gvwr and modelYear.
src/lib/cardog.ts
export function decodeVin(vin: string, step: string) {
return run<VinIdentity>({
step,
vin,
key: "decode",
method: "cardog.v2.vin.getByVin",
args: [vin],
http: `GET /v2/vin/${vin}`,
returns: "Identity card: year, make, model, and the refs for body, fuel, vehicle type and weight class",
live: (c) => c.v2.vin.getByVin(vin),
});
}src/lib/registration.ts
export function fieldsFromDecode(d: DecodeLike): RegFields {
const r = d.refs;
const coded = (key: FieldKey, ref: string | null | undefined, code: string | null, noun: string): RegField => {
if (code) return { key, value: code, decoded: code, source: "vin", gap: null };
const gap = ref
? `The decode says “${words(ref)}”, which has no ${noun} code on this form. Choose one.`
: `The VIN does not say the ${noun}. Enter it from the document or the vehicle.`;
return { key, value: null, decoded: null, source: "manual", gap };
};
const text = (key: FieldKey, v: string | number | null, noun: string): RegField =>
v !== null && v !== ""
? { key, value: String(v), decoded: String(v), source: "vin", gap: null }
: { key, value: null, decoded: null, source: "manual", gap: `The VIN does not say the ${noun}. Enter it from the document.` };
return {
year: text("year", d.year, "model year"),
make: text("make", d.make, "make"),
model: text("model", d.model, "model"),
body: coded("body", r.bodyStyle, bodyCode(r.bodyStyle), "body type"),
fuel: coded("fuel", r.fuelType ?? r.electrificationLevel, fuelCode(r.fuelType, r.electrificationLevel), "fuel"),
weightClass: coded("weightClass", r.gvwr, weightCode(r.gvwr), "weight class"),
vehicleType: coded("vehicleType", r.vehicleType, typeCode(r.vehicleType), "vehicle type"),
};
}
8. Add the model year's recall count
After the decode, read the recalls issued for the model and year and show the count as one line of information. It is not a check of the vehicle at the counter and it is not part of the registration.
The read is held per clerk for an hour, so recording the check does not pay for the same calls twice.
SDK call: cardog.v2.recalls.entity(modelYearRef, { limit: 5 })
What comes back. total and the newest campaigns issued for the model year, from Transport Canada, NHTSA and other authorities.
src/lib/cardog.ts
export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
return run<ModelYearRecalls>({
step,
vin,
key: "recalls",
method: "cardog.v2.recalls.entity",
args: [modelYearRef, { limit: 5 }],
http: `GET /v2/recalls/entity/${modelYearRef}?limit=5`,
returns: "How many recall campaigns were issued for the model year, and the newest five",
live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 5 }),
});
}src/lib/counter-read.ts
export async function readVin(
clerkId: string,
vin: string,
step = "Counter · decode"
): Promise<{ ok: true; read: CounterRead } | { ok: false; error: string; calls: CallRecord[] }> {
const decoded = await decodeVin(vin, step);
if (!decoded.data) return { ok: false, error: decoded.error ?? "The decode failed.", calls: [decoded.call] };
const d: VinIdentity = decoded.data;
if (!d.valid) {
const why = d.reasons?.length ? ` (${d.reasons.join(", ")})` : "";
return {
ok: false,
error: `This VIN does not decode to a vehicle${why}. Read it from the vehicle again, or register it by hand.`,
calls: [decoded.call],
};
}
const calls = [decoded.call];
let recalls: RecallLine | null = null;
if (d.refs.modelYear) {
const r = await readModelYearRecalls(d.refs.modelYear, vin, step);
calls.push(r.call);
if (r.data) recalls = recallLine(d.refs.modelYear, r.data);
}
const check = checkVin(vin);
const read: CounterRead = {
vin,
checkDigit: check.state === "check-mismatch" ? "mismatch" : "ok",
vehicle: [d.year, d.make, d.model].filter(Boolean).join(" ") || null,
fields: fieldsFromDecode(d),
recalls,
calls,
};
held.set(keyOf(clerkId, vin), { read, at: Date.now() });
return { ok: true, read };
}src/app/api/vin/decode/route.ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVin } from "@/lib/counter-read";
import { checkVin, normalizeVin } from "@/lib/vin";
/**
* POST { vin, force? } → the decode as registration fields, plus the
* model year's recall count. A VIN that fails its check digit is refused
* unless `force` is set: the clerk chose to record it as typed, and the
* check will be flagged.
*/
export async function POST(req: Request) {
const me = await getViewer();
if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
const body = (await req.json().catch(() => ({}))) as { vin?: string; force?: boolean };
const vin = normalizeVin(body.vin ?? "");
const check = checkVin(vin);
if (check.state === "empty" || check.state === "typing" || check.state === "invalid") {
return NextResponse.json({ ok: false, error: "A VIN has 17 letters and numbers, without I, O or Q." }, { status: 400 });
}
if (check.state === "check-mismatch" && !body.force) {
return NextResponse.json({ ok: false, error: check.message, checkDigit: "mismatch" }, { status: 422 });
}
return NextResponse.json(await readVin(me.id, vin));
}9. Check the document and record
The customer's ownership document carries a make and a year. Compare both with the VIN as the clerk types them; makes compare without case, spaces or punctuation, and nothing else is assumed.
A document that disagrees holds the check for a supervisor. A field entered or changed by hand, or a VIN recorded despite its check digit, flags it. The server works the outcome out again from the read it holds, so it never depends on the browser.
src/lib/registration.ts
export function compareDocument(fields: RegFields, stated: Stated): Mismatch[] {
const out: Mismatch[] = [];
const vinMake = fields.make.decoded;
const vinYear = fields.year.decoded;
if (vinMake && stated.make.trim() && squashMake(vinMake) !== squashMake(stated.make)) {
out.push({ field: "make", document: stated.make.trim(), vin: vinMake });
}
if (vinYear && stated.year.trim() && stated.year.trim() !== vinYear) {
out.push({ field: "year", document: stated.year.trim(), vin: vinYear });
}
return out;
}src/lib/registration.ts
export function statusFor(mismatches: Mismatch[], flags: Flag[]): Status {
if (mismatches.length) return "held";
if (flags.length) return "flagged";
return "clear";
}src/app/actions.ts
export async function recordCheck(input: RecordInput): Promise<RecordResult> {
const me = await getViewer();
if (!me) return { ok: false, error: "Your session has ended. Sign in again." };
const vin = normalizeVin(input.vin);
let read = heldRead(me.id, vin);
if (!read) {
const r = await readVin(me.id, vin, "Counter · record");
if (!r.ok) return { ok: false, error: r.error };
read = r.read;
}
const fields = withEntries(read.fields, input.entries);
const stated: Stated = { make: input.stated.make.trim(), year: input.stated.year.trim() };
const gaps = missing(fields, stated);
if (gaps.length) return { ok: false, error: `Still to enter: ${gaps.join(", ")}.` };
const mismatches = compareDocument(fields, stated);
const flags = flagsFor(fields, read.checkDigit);
const status = statusFor(mismatches, flags);
const note = input.note.trim().slice(0, 600) || null;
const row = createCheck({
vin,
clerkId: me.id,
clerkName: me.name,
status,
checkDigit: read.checkDigit,
year: fields.year.value ? Number(fields.year.value) : null,
make: fields.make.value,
model: fields.model.value,
fields,
stated,
mismatches,
flags,
recalls: read.recalls,
clerkNote: note,
calls: read.calls,
});
dropRead(me.id, vin);
revalidatePath("/log");
revalidatePath("/queue");
return { ok: true, id: row.id, number: row.number, status };
}
10. Build the supervisor queue
Held checks come first, then flagged ones, oldest first within each. The supervisor opens a check, sees each field and where it came from, and overrides it or returns it to the clerk, always with a reason that stays on the record.
src/lib/checks.ts
export function queue(): Check[] {
const rows = db
.select()
.from(schema.check)
.where(inArray(schema.check.status, ["held", "flagged"]))
.orderBy(asc(schema.check.createdAt))
.all()
.map(shape);
return [...rows.filter((r) => r.status === "held"), ...rows.filter((r) => r.status === "flagged")];
}src/app/actions.ts
export async function decideCheck(id: string, outcome: "approved" | "returned", reason: string): Promise<DecideResult> {
const me = await getViewer();
if (!me || me.role !== "supervisor") return { ok: false, error: "Only a supervisor can decide a check." };
const why = reason.trim();
if (why.length < 8) return { ok: false, error: "Give the reason in a few words, so the record says why." };
if (!decide(id, me.name, outcome, why.slice(0, 1000))) {
return { ok: false, error: "This check has already been decided." };
}
revalidatePath("/queue");
revalidatePath("/log");
revalidatePath(`/checks/${id}`);
return { ok: true };
}
11. Deploy
The counter 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
# Registration counter: one container, SQLite on a mounted volume.
# docker build -t dvs-counter .
# docker run -p 3029:3029 -v dvs-counter-data:/app/data \
# -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host dvs-counter
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 3029
CMD ["pnpm", "start"]terminal
docker build -t registration-counter .
docker run -p 3029:3029 -v counter-data:/app/data \
-e CARDOG_API_KEY=your_cardog_api_key \
-e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
-e BETTER_AUTH_URL=https://counter.example.com \
registration-counterNext steps
- VIN decode: Every field the decode returns.
- Recalls: Campaigns by model year, from each authority.
- Entity refs: How refs like body style and weight class are named.
Was this helpful?