Upload a photo of your dog. Get back a noir detective case file.
Good Boy Dossier hands your pet's photo to Google Gemini, which opens an official file on them: a codename, an invented case history, a deadpan read on their current expression, a confession to recent crimes, a bio in their own voice, and a legally meaningless Good Boy Score out of 100.
Then you download the whole thing as a shareable card.
Built for DEV's Weekend Challenge: "Dog Days".
The brief was dogs, and the interesting constraint in a weekend build isn't the model it's making the output feel personal. A generic AI dog caption is forgettable. A rubber-stamped government file that specifically calls out the mud on your dog's left paw is something people screenshot and send to their group chat.
So the whole app optimises for one moment: the download button producing an image worth posting.
One multimodal call to gemini-3.7-flash, made server-side in
app/api/analyze/route.ts via the
@google/generative-ai
SDK. The prompt logic lives in lib/gemini.ts.
The image goes up as inlineData (base64) alongside a text prompt, and Gemini
returns the seven dossier fields as JSON. Typical latency is 7–18 seconds,
which is why the loading state is something worth reading.
Getting reliable output took more than asking nicely:
- Structured output. The call sets
responseMimeType: 'application/json'plus aresponseSchemanaming all seven fields, so the model is constrained at the API level rather than merely instructed. - Evidence discipline in the prompt. The system instruction requires the model to anchor its writing in at least two things it can actually see coat texture, ear carriage, a collar, a toy, the couch. This is the single biggest difference between "cute generic caption" and "how did it know". On a test photo of a border collie it independently picked up the red bandana, both frisbees in frame, and the sideways tongue loll, then docked 22 points "for refusing to surrender the white disc".
- A model fallback chain.
GEMINI_MODELSis a list, not a string. Google retires hosted models with little noticegemini-2.0-flashandgemini-2.5-flashboth now answer "no longer available" so the first entry is pinned for reproducibility andgemini-flash-latestbacks it up as an alias that can't go stale. - Failure-aware retries, bounded to 3 calls. The next step is chosen from the kind of failure rather than retried blindly: malformed or truncated JSON retries the same model with a blunter prompt and a cooler temperature; a retired or overloaded model falls through to the next model; a safety block stops immediately, because that's a verdict on the photo. Backoff between attempts, since an instant retry just hits the same demand spike.
- A generous output budget.
maxOutputTokensis 4096 for a ~400-token answer. Gemini 3.x reasons before responding and those thinking tokens come out of the same budget at 1024 the model ran out mid-string and returned truncated, unparseable JSON. - Graceful degradation on bad JSON.
extractJson()tries the raw response, then strips```jsonfences, then falls back to the outermost{...}pair to survive a chatty preamble, then repairs trailing commas. - A runtime type guard.
isDossier()validates the shape before the object is allowed anywhere near React, so a malformed field can't blank the UI. - Output sanitising. Strings are trimmed and length-capped on word boundaries and the score is clamped to 1–100, so an over-enthusiastic response can't break the card layout or the exported PNG.
- Relaxed safety thresholds. A family dog on a sofa can trip a conservative
filter and cost the entire response, so the four standard harm categories are
set to
BLOCK_ONLY_HIGH. Prompt-level content limits keep it PG. - Non-dogs play along. Cats, rabbits, and lizards get investigated identically. A houseplant or a sandwich still gets a file the prompt frames the absurdity as the Bureau's bureaucratic problem rather than breaking character to point out you didn't upload a dog. (Fed a synthetic grey circle, it opened a file on "The Ambient Void" and gave it a 42 for "total geometric defiance".)
| Framework | Next.js 14 (App Router), TypeScript (strict) |
| Styling | Tailwind CSS 3 |
| AI | Google Gemini gemini-3.7-flash via @google/generative-ai |
| Image export | html-to-image |
| Fonts | Playfair Display, Courier Prime, Special Elite (self-hosted via next/font) |
| Storage | None. Everything is client-session state |
| Deploy | Vercel-ready |
Go to aistudio.google.com/apikey, sign in with a Google account, and click Create API key. The free tier is more than enough to run this.
cp .env.local.example .env.localThen open .env.local and paste your key in:
GEMINI_API_KEY=AIza...your_actual_keyThe variable is deliberately not prefixed with NEXT_PUBLIC_, so it stays
server-side and is never bundled into the browser. .env.local is gitignored.
npm install
npm run devOpen http://localhost:3000 and upload a dog.
If you started the dev server before creating
.env.local, restart it Next only reads env files at boot. The app will tell you so in plain English rather than failing silently.
npm run build # production build
npm start # serve the production build
npm run lint # eslint
npm run typecheck # tsc --noEmit- Push this repo to GitHub.
- Import Project in Vercel the Next.js preset is detected automatically.
- Add an environment variable
GEMINI_API_KEYwith your key, for all environments. - Deploy.
No other configuration is needed. The API route is already pinned to the Node
runtime with maxDuration = 60 to give a slow vision call room to finish.
app/
api/analyze/route.ts POST base64 image -> validate -> Gemini -> typed JSON
layout.tsx fonts, metadata, full-page grain + vignette
page.tsx static shell
error.tsx in-character error boundary
components/
DossierApp.tsx all client state; intake -> running -> filed
UploadPanel.tsx drag-drop, file picker, paste, preview
RunningTheFile.tsx typewriter status terminal
DossierCard.tsx the case file, in screen and export variants
ResultView.tsx actions + the off-screen export node
lib/
gemini.ts prompt, schema, JSON recovery, retry
image.ts decode, EXIF-correct, downscale (client-side)
download.ts PNG render, filename, download, native share
score.ts score bands and section order
types.ts shared contract + runtime guard
The upload is downscaled in the browser first. A modern phone photo is
4–12MB, which is past Vercel's serverless request body limit. lib/image.ts
decodes with createImageBitmap(file, { imageOrientation: 'from-image' }) so
EXIF rotation is respected, resizes to 1280px on the long edge, and re-encodes as
JPEG. Payloads land around 30–300KB, and the same data URL is reused for the
preview and the export so nothing is ever fetched cross-origin.
The downloaded PNG is rendered from a separate fixed-width node. Tailwind
breakpoints read the viewport, not the element so capturing the live
responsive card would hand phone users a narrow mobile-shaped PNG. Instead
DossierCard has an export variant at a fixed 1000px with no responsive
utilities, mounted off-screen and captured at 1.5x. A phone and a desktop produce
a byte-identical 1500px-wide image. (The capture ref points at an inner
statically-positioned div, because html-to-image copies the captured node's own
computed style onto its clone ref'ing the left:-20000px wrapper would push
the content out of the exported viewport and yield a blank PNG.)
The export texture is noise-free, and the scale is capped, both for file size. X rejects PNGs over 5MB precisely where these are meant to be posted. The screen UI uses real film grain, but per-pixel noise is close to incompressible: at 2x it accounted for 4.7MB of a 6.8MB export. Swapping in smooth large-scale gradient mottling for the export brought that down a lot, but real dossiers vary in height with how much Gemini writes, and a detailed photo plus Chrome's gradient dithering still landed finished files at 4.8–5.2MB straddling the limit. Capturing at 1.5x instead of 2x settles it: ~1.9–2.2MB for any dossier length, with the 15px body copy still rendering at an effective 22px, comfortably crisp past Instagram's 1080px display width.
- Your photo isn't stored. There's no database and no filesystem writes. The image lives in React state and is sent to Google's Gemini API for the duration of one request. It's gone when you reload.
- It's fiction. Every dossier is invented. The model is told not to guess breed, age, sex, or anything health-related, and to refer to any humans in frame only as "the handler".
- Accessibility. The typewriter loader announces once via a polite live
region instead of narrating every keystroke; all animation respects
prefers-reduced-motion; the export node isaria-hidden. - Security advisories.
npm auditreports advisories against Next.js 14 that are only patched in 15.5.21+. None reach this app: there are no Server Actions, no custom server, no rewrites, and the image optimizer is disabled with noremotePatterns. If you'd rather clear them,npm i next@latest eslint-config-next@latestthe code needs no changes for Next 15.
Built with Google Gemini for DEV's Weekend Challenge — Dog Days. No animals were interrogated.