Building DoodleTrove: An Artwork Archive with Next.js and Teable
We have a drawer. You probably have the same drawer. It fills with paper โ a purple elephant, something that is either a Christmas tree or a rocket. Someone has to decide what gets kept and what quietly disappears. I wanted to stop making that decision so I built DoodleTrove: photograph an artwork, tag whose it is and how old they were when they made it, and it's archived for good.
- nextjs
- teable
The camera
The capture screen is a big square that opens the camera:
<input type="file" accept="image/*" capture="environment" />capture="environment" is the whole mobile-camera story.
exifr reads the date so I don't have to
const exif = await exifr.parse(picked, ["DateTimeOriginal", "CreateDate"]);
const taken = exif?.DateTimeOriginal ?? exif?.CreateDate;exifr is small, and works in the browser. If there's no EXIF โ a screenshot, a scan โ today's date stands.
Browser-image-compression, before the network
A modern phone photo is 4โ8 MB. Uploading that from a kitchen with two bars is how uploads fail, and storing it is how you burn 100 GB faster than you'd think.
const compressed = await imageCompression(file, {
maxWidthOrHeight: 2000,
initialQuality: 0.85,
useWebWorker: true,
fileType: "image/jpeg",
});~500 KB, still sharp enough to print. useWebWorker: true matters more than it
looks โ without it the resize blocks the main thread and the UI freezes.
The philosophical point: the phone has a CPU. Resize there, not on a server you're paying for.
A Next.js route handler, and Zod at the boundary
The web app could have used server actions throughout. I deliberately built REST route handlers instead:
const createSchema = z.object({
title: z.string().trim().min(1).max(200),
childId: z.string().min(1),
dateMade: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
story: z.string().max(5000).optional(),
});Two reasons. Zod gives me one place where untrusted input becomes typed input โ validation and TypeScript types from the same declaration. And a REST surface means that if I ever build an Android client, it consumes the same API the website does rather than needing a backend written for it.
Signing in
For login, I used Google Auth so right now, signing in is Google-only. No passwords to manage, just one button to click. Anyone from the same household can sign in, and add an artwork to the archive.
Storing and testing the data
For storing the artworks, I used Teable, and the main reason is I have an access to their lifetime deal which gives me a sufficient storage.
The tradeoff is that Teable has some limits, like expiring image links and rate limits on requests. To keep things flexible, I built one clear interface that everything else in the app talks to, so nothing needs to "know" Teable is behind the scenes. Every request is tied to a household, not a person. This setup also means switching to a different database later would just mean writing a new version of that interface, not rebuilding the whole app.
For testing, I skipped fake/mock data and tested directly against the real system. That choice paid off immediately โ it caught three sneaky bugs in one afternoon: a date calculation that silently failed and returned nothing, dates showing up a day off due to timezone differences, and new entries missing information that only appears after creation. Testing against the real thing showed problems that fake data would have hidden completely.
The three decisions I'd make again
The stack matters less than three decisions:
- Design the ownership model before the schema. Household-not-user is a ten-second decision that would have been a migration in month three.
- Put one interface between your app and your data. It's what makes an unconventional database choice survivable.
- Test the seam against the real thing. That's where reality disagrees with you, and it disagrees quietly.