How I Built an AI Pet Video Generator with TanStack Start and ByteDance Seedance
A few months ago I set out to build something that felt genuinely magical. Not another CRUD dashboard, not another productivity tracker. I wanted to build something my non-technical friends would actually use and enjoy.
The result is PetVideo Generator — you upload a photo of your cat or dog, describe what you want them to do (“my corgi surfing a giant wave at sunset”), and the app uses AI to turn that into a short cinematic video. No editing skills, no prompt engineering — just upload, describe, and download.
I’ll walk through the stack, why I went all-in on TanStack, how I integrated ByteDance’s Seedance 2.5 for video generation, and a few sharp edges I hit along the way.
Going All-In on the TanStack Ecosystem
I have a rule for side projects: pick a stack and commit. Half-measures — like mixing Next.js pages router with React Query and a separate form library you don’t fully understand — are how projects die in week three. This time, I committed to TanStack end to end.
Here’s the full lineup:
Concern TanStack piece What it replaced for me Framework & routing TanStack Start (Vite 8 + Nitro) Next.js App Router Server state TanStack Query v5 useEffect + fetch / SWR Forms TanStack Form v1 + Zod v4 React Hook Form Tables TanStack Table v8 Hand-rolled table logic UI primitives shadcn/ui v4 + Tailwind CSS 4 — Auth better-auth (Drizzle adapter) NextAuth / Clerk Database Drizzle ORM on PostgreSQL Prisma i18n Paraglide JS next-intlLet me go through each piece and explain why it earned its spot.
TanStack Start — the Foundation
TanStack Start is still in RC, which worried me at first. But after a weekend of building, the worry evaporated. Here’s what I liked:
File-based routing without the magic. Routes live under src/routes/ as flat files and folders. A page at /pricing is literally src/routes/pricing.tsx. Route groups use parentheses like (auth)/sign-in.tsx — same convention as Next.js, but without the "use client" directives or RSC boundary confusion. Every component is a regular React component. If it runs on the server, you write a loader. If it runs on the client, you write a component. That’s it.
Loaders are server-side, not magical. Need to fetch data before rendering a page? Export a loader function:
export const Route = createFileRoute('/pricing')({
loader: async () => {
const plans = await getPublicPlans();
return { plans };
},
component: PricingPage,
});
Enter fullscreen mode Exit fullscreen mode
The loader runs on the server during SSR and can access your database directly — no API route indirection, no getServerSideProps ceremony. The returned data is serialized and available via Route.useLoaderData() in your component, fully typed.
Nitro under the hood. TanStack Start bundles with Nitro, which means your server endpoints live in src/routes/api/ and are just exported functions. Need a webhook handler? src/routes/api/webhook.ts exports a POST function. It deploys to Node.js, Cloudflare Workers, or anywhere Nitro supports — no lock-in.
Metadata without the boilerplate. Each route has a head export that receives loader data:
export const Route = createFileRoute('/pricing')({
loader: () => ({ title: 'Pricing — PetVideo Generator' }),
head: ({ loaderData }) => ({
meta: loaderData ? [{ title: loaderData.title }] : [],
}),
component: PricingPage,
});
Enter fullscreen mode Exit fullscreen mode
This pattern stayed consistent across all 35+ routes in the app. Once you learn it once, you know it everywhere.
TanStack Query — Server State Done Right
I used to manually manage server state with useEffect + fetch + useState for loading/error/data. I’d eventually add a cache layer that I maintained poorly. TanStack Query eliminates all of that.
For the admin panel’s user list, a paginated query looks like this:
const usersQuery = useQuery({
queryKey: ['admin-users', page, search],
queryFn: () => apiGet(`/api/admin/users?page=${page}&search=${search}`),
placeholderData: keepPreviousData,
});
Enter fullscreen mode Exit fullscreen mode
keepPreviousData is quietly one of the best features — when the user clicks to page 2, the table keeps showing page 1’s data until page 2 loads. No flash of empty rows, no loading spinner where data should be. It feels fast because it is fast.
Mutations follow the same pattern across the entire app:
const grantCredits = useMutation({
mutationFn: (vars: { userId: string; amount: number }) =>
apiPost('/api/admin/users/credits', vars),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
toast.success('Credits granted');
},
});
Enter fullscreen mode Exit fullscreen mode
Invalidate on success, toast on success, toast on error. Every mutation in the codebase follows this shape, which made the app predictable to work on even as it grew.
TanStack Form + Zod
Forms are where most React apps accumulate technical debt. You start with controlled inputs, then add validation, then need field-level errors, then realize your form state is 200 lines of useState and useEffect.
TanStack Form takes a different approach. You define the form shape and validation schema once, and the library manages all the state:
const form = useForm({
defaultValues: { email: '', password: '' },
validators: {
onChange: zodValidator(
z.object({
email: z.string().email(),
password: z.string().min(8),
})
),
},
onSubmit: async ({ value }) => {
await signIn(value);
},
});
Enter fullscreen mode Exit fullscreen mode
Paired with a thin TextField wrapper component that reads form.Field state and renders a labeled input, every form in the app — sign in, sign up, settings, admin dialogs — follows the same structure. No per-form state management, no duplicated validation logic between client and server.
TanStack Table for Admin Panels
The admin panel has several data-heavy tables: users, subscriptions, payments, credits, posts. Each one needs server-side pagination, search, and sortable columns. TanStack Table handles the headless logic — you provide the data shape, define columns, and it gives you back everything you need for rendering:
-
table.getRowModel().rowsfor the visible rows -
table.getHeaderGroups()for column headers with sort indicators - Built-in pagination state that plugs into your query parameters
The loading prop on the DataTable component (which wraps TanStack Table) shows a subtle shimmer overlay without unmounting the current rows — again, no jarring loading states.
The consistency across TanStack libraries is the real win. They share the same mental model — define a configuration object, get back reactive state, plug it into your UI. After learning Query, picking up Form and Table took hours, not days.
Working with ByteDance’s Seedance 2.5
Now for the fun part — the AI that actually makes the videos.
What Is Seedance?
Seedance is ByteDance’s image-to-video model. If you’ve been following the AI video space, you’ve probably heard of Runway, Pika, or Kling. Seedance sits in the same category but has a few characteristics that made it a good fit for this project.
Seedance 2.0 was already solid — it produced clean motion, handled pet fur and anatomy reasonably well, and didn’t introduce too many visual artifacts. Seedance 2.5, which released earlier this year, brought some meaningful improvements:
- Better motion coherence. Earlier image-to-video models would sometimes warp the subject — a dog’s leg might stretch weirdly, or the background would shimmer. Seedance 2.5 handles motion much more naturally. Pets running, jumping, or turning their heads look like actual pets doing those things, not glitchy approximations.
- Higher temporal consistency at longer durations. It supports up to 15 seconds of output at 720p. The frame-to-frame consistency at 10+ seconds is where it pulls ahead of earlier versions — fewer morphing artifacts and smoother transitions.
- Prompt adherence. The model respects camera direction cues — “slow push-in,” “dolly left,” “bird’s eye view” — more reliably than its predecessor. This matters because cinematic camera motion is what makes the output feel like a real video rather than a animated still.
How I Integrated It
The generation pipeline has three stages, and Seedance handles the third:
Stage 1 — Upload. Users drop or paste an image. It uploads to Cloudflare R2 (S3-compatible object storage). Before anything else, a quick vision-model check confirms the image actually contains a pet — saves people from burning credits on generation attempts that’ll fail or produce nonsense.
Stage 2 — Prompt optimization. Most people write terrible prompts. “Make my cat dance” isn’t going to give Seedance enough to work with. So the app runs the user’s casual description through an LLM that rewrites it into cinematic language:
User types: "my golden retriever as a wizard"
System generates: "A majestic golden retriever wearing flowing wizard robes,
holding a wooden staff with a glowing crystal, standing in a candlelit stone
chamber with floating embers and magical particles, slow camera push-in,
cinematic lighting, shallow depth of field"
Enter fullscreen mode Exit fullscreen mode
This step takes under a second and runs server-side. The difference in output quality is night and day — Seedance responds dramatically better to detailed, cinematography-aware prompts than to casual descriptions.
Stage 3 — Video generation with Seedance. The original photo and the optimized prompt get sent to the Seedance 2.5 endpoint. The model takes the static image and the text description and generates a video clip — typically 5 to 15 seconds, at either 480p or 720p.
The generation itself takes 30–90 seconds depending on duration and resolution. The app polls for completion and saves the resulting video URL to the user’s gallery. Users can preview, download, or share their videos from there.
What Seedance Excels At (and Where It Struggles)
After generating thousands of videos during development and testing, I’ve developed a sense for what works:
Pet close-ups are excellent. Seedance handles fur texture, ear movement, and facial expressions well. A close-up of a cat slowly blinking and turning its head looks genuinely good — the kind of result that makes people say “how is this AI?”
Action at a distance is weaker. If the pet is small in the frame — say, a dog running across a field — the motion can get mushy. The model has fewer pixels to work with for the subject, and the quality drops noticeably. My rule of thumb: if you can’t clearly see the pet’s eyes in the source photo, the result won’t be great.
Simple backgrounds work better than complex ones. A pet on grass or a plain wall generates cleaner motion than a pet in a busy living room. The model sometimes confuses background elements with the subject. When I prompt users to “use a photo with a clean, uncluttered background,” the success rate jumps noticeably.
Camera motion cues are genuinely useful. Adding “slow zoom out,” “gentle pan right,” or “static camera, subject moves toward lens” to the prompt changes the output. This feels less like a gimmick and more like a fundamental capability — you can direct the shot, not just describe the scene.
Why Not Other Models?
I evaluated a few alternatives. Runway Gen-3 produces great results but the API pricing doesn’t work for a consumer SaaS with a low price point. Kling is strong on human faces but less consistent with animals. Pika is fast but resolution-limited.
Seedance hit the sweet spot for this use case: good enough quality for pet content (where slight imperfections are actually charming rather than off-putting), reasonable generation speed, and a cost structure that let me offer a free tier without losing money on every user.
The Credit System
This is the boring-but-critical part nobody blogs about. When you charge users in “credits” and those credits get consumed by variable-cost API calls, you need a real accounting system.
A few things I learned:
FIFO consumption matters. Users accumulate credits from multiple sources — signup bonus, subscription renewal, credit pack purchases. Each batch has its own expiration date. When a generation costs 22 credits, the system needs to consume from the batch that expires soonest. Implementing this correctly with Drizzle ORM meant writing window functions and atomic update queries, not just a credits -= amount decrement.
Reserve-then-consume, not consume-then-refund. When a user kicks off a generation, you reserve credits. If the generation succeeds, you consume them. If it fails, you release them. If you consume first and refund on failure, you create a small window where users can overdraft by submitting multiple requests simultaneously. The reserve pattern (with optimistic UI showing “pending” credit usage) prevents this.
Expiration is a feature, not just a cost-control mechanism. Credits that expire encourage regular usage, which increases the chance users will upgrade. But be transparent about it — the dashboard shows exactly when each credit batch expires, and the reminder email goes out a week before.
The whole credit module — FIFO logic, expiration, partial consumption, automated top-ups — ended up being about 400 lines of TypeScript. Not trivial, but self-contained. It imports nothing from the rest of the app except the database client and Drizzle schemas.
Monetization Without Feeling Scummy
Pricing took more iteration than any technical decision. I studied the dominant player in this space ($15–129/month) and set a straightforward goal: every plan cheaper than the equivalent, with a genuinely usable free tier.
Plan Monthly Credits/mo Highlights Free $0 1 gen + download/week No watermark, no credit card Basic $12.99 300 All modes, up to 720p Pro $24.99 800 Priority queue, longer durations Ultra $109.99 5,000 Top priority, dedicated supportThe free tier gives one generation and download per week — enough to try it, share something, and decide if you want more. No card, no trial expiration countdown. If someone uses it once a week forever, that’s fine — word of mouth from free users is its own kind of revenue.
The paid tiers run at a thin margin on short/low-res generations but make it up on the longer, higher-resolution videos that paying users naturally gravitate toward. I also added credit packs (small one-time purchases, no subscription) and lifetime plans (one payment, permanent access) for people who prefer those models.
Stuff I’d Do Differently
Start with webhooks, not polling. I poll Seedance’s job status endpoint every few seconds to check if a generation is done. For low volume this is fine. At scale, switching to webhook callbacks would cut wasteful API calls and reduce perceived latency. This is on the roadmap but would have been easier to build from day one.
The credit system needed more upfront modeling. FIFO ordering, partial consumption across multiple pools, expiration, auto-grant on signup, revocation on refunds — it’s a deeper domain than it looks. Next time I’d sketch the full state machine before writing a single service function.
i18n from the start was the right call. I wanted English and Chinese from day one, so I wired up Paraglide JS early. The Inlang compiler tree-shakes unused messages at build time, which is nice, but keeping 675+ translation keys in sync during rapid development is genuinely tedious. There’s no great tooling for this yet.
Wrapping Up
Building a consumer AI product is a different beast from building a dev tool or a B2B SaaS. The quality bar is higher (people compare it to Hollywood, not to a spreadsheet), the margin math is tighter (you’re paying per API call, not per server-hour), and the “wow factor” is the entire product — there’s no enterprise feature checklist to fall back on.
PetVideo Generator is live and free to try. Upload a photo of your pet, give it a prompt, and see what Seedance 2.5 can do. No credit card, no time-limited trial — just one generation a week you can actually use and download.
If you’re curious about the underlying SaaS architecture — auth, payments, admin panel, the credit engine — the backend runs on ShipAny, a headless SaaS boilerplate. Their source is on GitHub and their docs are at shipany.ai. I mention it because a lot of indie devs burn weeks on infrastructure before they ever write a line of product code. Having that layer done meant I went from idea to working prototype in about a week.
Would love to hear what you’re building — or how you’d approach this differently. Drop a comment.
답글 남기기