How I Built an AI Pet Video Generator with TanStack Start and ByteDance Seedance
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-intl |
Let 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/pricingis literallysrc/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 aloader. 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,
});
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.tsexports 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
headexport that receives loader data:
export const Route = createFileRoute('/pricing')({
loader: () => ({ title: 'Pricing - PetVideo Generator' }),
head: ({ loaderData }) => ({
meta: loaderData ? [{ title: loaderData.title }] : [],
}),
component: PricingPage,
});
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,
});
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');
},
});
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);
},
});
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 rowstable.getHeaderGroups()for column headers with sort indicators- Built-in pagination state that plugs into your query parameters
- The
loadingprop on theDataTablecomponent (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 an 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"
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 -= amountdecrement.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 | Price | 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 support |
The 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‑resolution generations; the margin on longer/higher‑resolution outputs is better, but the whole model works because most users start with short clips and upgrade as they get hooked.
Comments
No comments yet. Start the discussion.