Build a Tweet SaaS with Next.js, Prisma Postgres, and Ollama
In this vibe coding tutorial, you'll build TweetSmith, a tweet polishing application that rewrites rough draft tweets into well-formatted content using AI. The AI runs locally on your machine, so no API keys are required.
You'll use an AI coding assistant to build a full-stack application with:
- Next.js: React framework for production
- Prisma ORM: Type-safe database access
- Prisma Postgres: Serverless PostgreSQL database
- Ollama: Run LLMs locally on your machine
- UploadThing: File uploads for tweet images
- Lucide React: Icon library
By the end of this tutorial, you'll have a working application where users can paste draft tweets, transform them with AI, save their favorites, and even attach images, all built with AI-assisted development.
Watch this step-by-step walkthrough of the entire build process:
https://www.youtube.com/embed/QIuR2_pKFXA
Before starting this tutorial, make sure you have:
- Node.js 20+ installed
- Ollama installed on your machine
- An AI coding assistant (Cursor, Windsurf, GitHub Copilot, etc.)
- Basic familiarity with React and TypeScript
Set up Ollama first so the local model is ready to transform tweets. It runs entirely on your machine, with no API keys, usage limits, or internet connection required.
Open your terminal and download the Gemma 3 model:
ollama pull gemma3:4bYou should see a progress indicator like "pulling manifest…95%". This downloads approximately 3.3GB.
Test that the model responds:
ollama run gemma3:4bType something and confirm it responds. Press Ctrl+C to exit.
You can also verify the API is accessible:
curl http://localhost:11434/api/tagsYou should see JSON output showing gemma3:4b is installed:
{
"models": [{
"name": "gemma3:4b",
"family": "gemma3",
"parameter_size": "4.3B",
"quantization_level": "Q4_K_M"
}]
}Your local LLM is ready. Ollama runs as a background service, so you don't need to keep a terminal open.
Create a new Next.js application:
bunx create-next-app@latest tweetsmithpnpm dlx create-next-app@latest tweetsmithyarn dlx create-next-app@latest tweetsmithnpx create-next-app@latest tweetsmithWhen prompted, select:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
src/directory: No- App Router: Yes
- Turbopack: Yes (optional)
- Import alias: @/* (default)
Navigate into your project:
cd tweetsmithStart your development server to verify everything works:
bun run devpnpm run devyarn devnpm run devOpen http://localhost:3000. You should see the default Next.js page.
Create a minimalist, dark-themed UI inspired by tools like Typefully. Copy and paste this prompt to your AI assistant:
I have a fresh Next.js 15 project with Tailwind CSS already set up.
I need you to create a minimalist single-page UI for a TweetSmith app called "TweetSmith".
**Design requirements:**
- Dark theme only (no light/dark toggle)
- Typefully-inspired aesthetic: sophisticated, clean, minimal
- Color palette:
- Background: #141414 (soft charcoal)
- Card/inputs: #1c1c1c
- Borders: #2a2a2a
- Muted text: #888888
- Foreground/text: #fafafa
- Typography: Geist font (already configured), small refined sizes
- Labels should be uppercase with letter-spacing
**What to create:**
1. Update `app/globals.css` with the dark color palette and CSS variables
2. Update `app/layout.tsx` with proper metadata (title: "TweetSmith") and force the dark background
3. Create `app/components/TweetTransformer.tsx` - a client component with:
- A textarea input for draft tweets
- A character counter (X / 280)
- A "Transform" button (disabled when empty)
- An output section for the result (only visible when there's a result)
- Loading state ready for future API integration
- The handleTransform function should just console.log for now
4. Update `app/page.tsx` with:
- Simple header with app name and tagline
- The TweetTransformer component
- Subtle footer saying "powered by ollama"
Keep it minimal, no extra features, just clean functional UI. Maximum width should be max-w-md for a focused feel.- Restart your dev server if needed
- You should see a dark-themed page with a textarea and transform button
- Type something and verify the character counter updates
- The button should be disabled when the textarea is empty
Once it looks good, commit your changes:
git add .
git commit -m "Add TweetSmith UI"Wire the UI to your local Ollama instance with a helper file and an API route.
Copy and paste this prompt:
Create `app/lib/ollama.ts` - a helper file to communicate with a local Ollama LLM.
Requirements:
- Ollama runs at http://localhost:11434
- Model name: "gemma3:4b"
- Use the /api/generate endpoint
- Create TypeScript types for OllamaRequest and OllamaResponse
- Export a function `generateWithOllama(prompt: string)` that:
- Sends a POST request with the prompt
- Uses stream: false (no streaming, keep it simple)
- Returns the response text as a string
- Throws an error if the request fails
Keep it minimal with clear comments explaining what each part does.Copy and paste this prompt:
Create `app/api/transform/route.ts` - a Next.js API route that transforms tweets.
Requirements:
- POST endpoint that accepts { draft: string } in the body
- Validate that draft exists and is a string (return 400 if invalid)
- Use the generateWithOllama function from "@/app/lib/ollama"
- Build a prompt that tells the LLM to:
- Act as a tweet formatter
- Make the draft cleaner, more engaging, well-formatted
- Keep it under 280 characters
- Return only the improved tweet, nothing else
- Return { transformed: string } on success
- Return { error: string } with status 500 if Ollama fails
- Add a helpful error message asking if Ollama is running
Use NextRequest and NextResponse from next/server.Copy and paste this prompt:
Update the TweetTransformer component to call the transform API:
1. Add an "error" state (useState) to handle errors
2. Update handleTransform to:
- Reset error and result states first
- Set loading to true
- Call POST /api/transform with { draft: draftTweet }
- On success: set the transformed tweet from response
- On error: set error message from the catch
- Use finally to always set loading to false
3. Display error message in red below the button when there's an error
Keep the existing UI structure, just wire up the real API call.- Make sure Ollama is running in the background
- Type a draft tweet like "just shipped a new feature, its pretty cool i think"
- Click Transform
- You should see a polished version appear after a few seconds
If you get an error, check that:
- Ollama is running (
curl http://localhost:11434/api/tags) - The model name matches (
gemma3:4b)
You might notice the LLM returns the exact same output every time. That's because LLMs are deterministic by default: given the same input, they produce the same output. Adding a "temperature" parameter introduces controlled randomness that makes each response slightly different while keeping it coherent.
Update the ollama.ts file to add temperature for response variety:
1. Add an optional "options" field to OllamaRequest type with temperature?: number
2. In the request body, add:
options: {
temperature: 0.7
}Now each transform gives slightly different results.
git add .
git commit -m "Connect to Ollama LLM for tweet transformation"Right now, every tweet gets the same treatment, but users have different needs: some want short tweets while others need the full 280 characters, and some want emojis while others don't. Give users control over the output with a collapsible filter panel for character limits and emoji usage.
First, install Lucide for icons:
bun add lucide-reactpnpm add lucide-reactyarn add lucide-reactnpm install lucide-reactThen copy and paste this prompt:
Create `app/components/FilterOptions.tsx` with filters for tweet generation.
Requirements:
- Export a `Filters` type with: maxChars (number) and emojiMode ("none" | "few" | "many")
- Export two components:
1. `FilterButton` - a toggle button showing current filter values (e.g., "Filters 280 · few")
2. `FilterPanel` - the expanded controls panel
FilterButton props: isOpen, onToggle, filters
FilterPanel props: filters, onFiltersChange
FilterPanel should include:
- A range slider for maxChars (100-280, step 20)
- Three icon buttons for emoji mode using Lucide icons:
- Ban icon for "none"
- Smile icon for "few"
- SmilePlus icon for "many"
- Display inline with dividers between sections
- Compact design with small buttons (h-7 w-7)
Use SlidersHorizontal and ChevronDown from lucide-react for the button.
Style: dark theme, rounded-lg, border-border, bg-card.Filters control the format, but not the voice. A tech founder tweets differently than a lifestyle blogger, so let users describe their tone, style, and audience, and the AI can generate tweets that sound like them. Add a context panel where users describe their voice; it is saved to localStorage so it persists across sessions.
Create `app/components/ContextSettings.tsx` with context management.
Export two components:
1. `ContextButton` - toggle button with User icon from lucide-react
- Props: isOpen, onToggle, hasContext (boolean)
- Show a small dot indicator when context is set
2. `ContextPanel` - just the textarea
- Props: onContextChange
- Load/save to localStorage with key "TweetSmith-context"
Use User and ChevronDown icons from lucide-react.
Keep the textarea at 2 rows, placeholder about style/tone.Update `app/components/TweetTransformer.tsx` to use the new component structure.
Changes:
1. Import ContextButton, ContextPanel from ContextSettings
2. Import FilterButton, FilterPanel, Filters from FilterOptions
3. Add state for which panel is open: type OpenPanel = "none" | "context" | "filters"
4. Track hasContext state (check localStorage on mount)
Layout structure:
- Settings row: flex container with gap-2 containing both buttons INLINE
- Below the row: conditionally render either ContextPanel or FilterPanel (only one at a time)
- Clicking one panel closes the other
This keeps both buttons always on the same line, with expanded content appearing below.
Default filters: maxChars 280, emojiMode "few"Update `app/api/transform/route.ts` to use the filter values.
Key changes:
1. Extract filter values directly: maxChars and emojiMode with defaults (280, "few")
2. Build emoji rule as a simple string based on mode
3. Put STRICT LIMITS at the TOP of the prompt (most important):
- "Maximum {maxChars} characters (THIS IS MANDATORY)"
- Emoji rule
- "No hashtags"
4. Add context as "Author style:" if provided
5. Keep GUIDELINES brief: lead with value, sound human, be engaging
6. End with: 'Respond with ONLY the rewritten tweet. No quotes, no explanation.'
Remove any complex buildFilterRules function - inline everything for clarity.
The prompt should be shorter and more direct for better local LLM compliance.- Open the Filters panel and adjust the character limit
- Toggle between emoji modes
- Add some context like "Tech founder, casual tone"
- Transform a tweet and verify the output respects your settings
git add .
git commit -m "Add filter options and context settings"Plain text output works, but a preview styled like a real Twitter/X post, with profile picture, verified badge, and formatting, makes the result easier to judge and share. Create a tweet preview card with loading skeletons:
Create `app/components/TweetPreview.tsx` - a tweet-like preview card with 3 states.
Props: content (string | null), isLoading (boolean)
Structure:
1. Header with profile image, name, verified badge, and handle
2. Content area (changes based on state)
3. Footer with character count or placeholder
Three states:
1. EMPTY (no content, not loading):
- Show placeholder text styled like a tweet but in text-muted
- Example: "Follow us on X to stay updated on all the latest features and releases from Prisma! 🚀\n\nYour polished tweet will appear here ✨"
- Footer shows "prisma.io"
2. LOADING (isLoading true):
- Show 3 animated skeleton bars with animate-pulse
- Different widths: 90%, 75%, 60%
- Footer skeleton bar
3. CONTENT (has content):
- Show the actual tweet text
- Copy button in header (using Copy/Check icons from lucide-react)
- Footer shows "X / 280 characters"
Use Image from next/image for the profile picture.
Add verified badge as inline SVG (Twitter blue checkmark).Add a logo image (like icon-logo.png) to your public/ folder, then:
Update `app/page.tsx` to use a logo image instead of text for the header.
Changes:
1. Import Image from "next/image"
2. Replace the h1 text with an Image component:
- src="/icon-logo.png" (or your logo file)
- width={80} height={80}
- Add className="mb-3" for spacing
3. Keep the tagline text below: "polish your tweets with AI"
4. Use flex flex-col items-center on the header
The header should now show: Logo image centered, tagline below.git add .
git commit -m "Add tweet preview card and logo"Add a database to save favorite tweets, using Prisma ORM with Prisma Postgres.
bun add prisma@prev tsx --dev
bun add @prisma/adapter-pg @prisma/client@7 dotenvpnpm add prisma@prev tsx --save-dev
pnpm add @prisma/adapter-pg @prisma/client@7 dotenvyarn add prisma@prev tsx --dev
yarn add @prisma/adapter-pg @prisma/client@7 dotenvnpm install prisma@prev tsx --save-dev
npm install @prisma/adapter-pg @prisma/client@7 dotenvbunx --bun prisma init --output ../app/generated/prisma
bunx create-dbpnpm prisma init --output ../app/generated/prisma
pnpm dlx create-dbyarn prisma init --output ../app/generated/prisma
yarn dlx create-dbnpx prisma init --output ../app/generated/prisma
npx create-dbReplace the generated DATABASE_URL in .env with the postgres://... connection string from npx create-db.
Replace the contents of prisma/schema.prisma with:
generator client {
provider = "prisma-client"
output = "../app/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model SavedTweet {
id String @id @default(cuid())
original String // The draft tweet input
transformed String // The polished/transformed tweet
context String? // Optional user context/style used
imageUrl String? // Optional image URL
imageAlt String? // Optional alt text for accessibility
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Create lib/prisma.ts:
import { PrismaClient } from "../app/generated/prisma/client"
import { PrismaPg } from "@prisma/adapter-pg"
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
})
const globalForPrisma = global as unknown as { prisma: PrismaClient }
const prisma = globalForPrisma.prisma || new PrismaClient({
adapter,
})
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
export default prismaUpdate your package.json scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:test": "tsx scripts/test-database.ts",
"db:studio": "prisma studio"
}
}Create scripts/test-database.ts:
import "dotenv/config"
import prisma from "../lib/prisma"
async function testDatabase() {
console.log("🔍 Testing Prisma Postgres connection...\n")
try {
console.log("✅ Connected to database!")
console.log("\n📝 Creating a test saved tweet...")
const newTweet = await prisma.savedTweet.create({
data: {
original: "just shipped a new feature, its pretty cool i think",
transformed: "Just shipped a new feature! 🚀 Pretty excited about this one ✨",
context: "Tech founder, casual tone",
},
})
console.log("✅ Created saved tweet:", newTweet)
console.log("\n📋 Fetching all saved tweets...")
const allTweets = await prisma.savedTweet.findMany()
console.log(`✅ Found ${allTweets.length} saved tweet(s)`)
console.log("\n🎉 All tests passed! Your database is working perfectly.\n")
} catch (error) {
console.error("❌ Error:", error)
process.exit(1)
}
}
testDatabase()bunx prisma db push
bunx prisma generate
bun run db:testpnpm prisma db push
pnpm prisma generate
pnpm run db:testyarn prisma db push
yarn prisma generate
yarn db:testnpx prisma db push
npx prisma generate
npm run db:testYou should see success messages. Open Prisma Studio to view your data:
bun run db:studiopnpm run db:studioyarn db:studionpm run db:studioCopy and paste this prompt:
Create `app/api/tweets/route.ts` with GET, POST, and DELETE handlers.
Requirements:
- GET: Fetch all saved tweets ordered by createdAt desc
- POST: Save a new tweet with { original, transformed, context?, imageUrl?, imageAlt? }
- DELETE: Delete a tweet by id (passed as query param ?id=xxx)
Use try-catch blocks and return appropriate error responses.
Import prisma from "../../../lib/prisma"Test the API with curl:
# Save a tweet
curl -X POST http://localhost:3000/api/tweets \
-H "Content-Type: application/json" \
-d '{"original":"test draft","transformed":"Test polished! ✨"}'
# Get all tweets
curl http://localhost:3000/api/tweetsgit add .
git commit -m "Add Prisma Postgres database"Users often want to save their best transformed tweets for later, whether they are not ready to post yet or want to build a collection. Add a save button to the tweet preview card with visual feedback:
Add a Save button to save transformed tweets to the database.
Requirements:
- Add a Save button next to the Copy button in TweetPreview
- Use POST /api/tweets with { original, transformed, context }
- Pass original (draft) and context from TweetTransformer to TweetPreview
UX States:
- Default: Bookmark icon with hover scale effect
- Saving: Spinning Loader2 icon + "Saving" text
- Saved: Green tinted background (emerald-500/10), checkmark icon with zoom animation
Important: The "Saved" state must persist until a NEW tweet is generated. Use useRef to track previous content and useEffect to reset saved state only when content changes. Do not use setTimeout to reset the saved state.
Match the minimal dark aesthetic of the app (200ms ease-out transitions, subtle hover states).Saved tweets need a place to live. Build a slide-in library panel where users can browse their saved tweets, copy them for posting, or load them back as drafts to iterate further. This completes the workflow from draft to polish to save to reuse:
Add a Library feature to browse and reuse saved tweets.
Components to create:
- LibraryButton.tsx - Fixed top-right button with count badge
- LibraryPanel.tsx - Slide-in panel from right (360px, backdrop blur)
- SavedTweetCard.tsx - Tweet cards that look like published tweets
LibraryButton:
- Fixed position top-right (fixed top-6 right-6)
- Shows saved tweets count as badge
- Toggles panel open/close
LibraryPanel:
- Slides in from right with 300ms ease-out animation
- Backdrop overlay with blur
- Header with title, count, and close button
- Scrollable list of SavedTweetCard components
- Empty state with icon when no tweets saved
- Fetches tweets from GET /api/tweets when opened
SavedTweetCard:
- Looks like a real published tweet (profile image, name, verified badge, handle, date)
- Shows transformed tweet content only (not original)
- Footer: character count on left, Copy/Delete icons on right (subtle, brighten on hover)
- Click anywhere on card → loads transformed text into Draft textarea and closes panel
- Delete shows inline confirmation (Cancel/Delete buttons), not a modal
Integration:
- Add library state to TweetTransformer (isOpen, count)
- Fetch count on mount and after saving
- Pass onUseAsDraft callback to set draft and clear transformed tweet
- Refresh count when panel closes (in case tweets were deleted)
Styling: Match minimal dark aesthetic - subtle borders, muted colors, smooth 200ms transitions.- Save a few transformed tweets
- Click the Library button in the top-right
- Verify your saved tweets appear in the panel
- Click a tweet to load it back into the draft
- Delete a tweet and verify it disappears
git add .
git commit -m "Add tweet library with save/browse/delete"Add a choice of dark themes so users can pick one that matches their style, from purple twilight to warm desert tones to newspaper grey:
Add a theme system to my app with 3 dark themes and a minimal theme switcher.
THEMES:
1. "Disco" - Purple/violet twilight vibes
- Background: #17171c (deep blue-black)
- Accent: #a78bfa (soft violet)
2. "Dust" - Desert warmth, amber tones
- Background: #1a1816 (warm charcoal)
- Accent: #d4a574 (warm amber/sand)
3. "Press" - Old newspaper, pure greys
- Background: #262626 (true grey)
- Accent: #a3a3a3 (neutral grey)
IMPLEMENTATION:
- Use CSS custom properties (:root and [data-theme="..."]) for all colors
- Add a subtle radial gradient glow at the top of the page using the accent color
- Create a ThemeSwitcher component with small colored dots (one per theme)
- Place the switcher in the footer for minimal UI impact
- Persist theme choice in localStorage
- Add smooth transitions when switching themes (0.3s ease)
- Prevent transition flash on page load with a "no-transitions" class
UX REQUIREMENTS:
- Each dot shows the accent color of that theme
- Selected theme has a subtle ring + slight scale up
- Unselected themes are dimmed (opacity 40%) and brighten on hover
- Theme changes should animate smoothly across all UI elementsThe filter and context panels currently appear and disappear abruptly. Use the CSS Grid height animation trick to animate expand and collapse without the layout jumps of traditional height transitions:
Add smooth animated collapsible panels that expand/collapse without layout jumps.
ANIMATED PANEL COMPONENT:
Create a reusable AnimatedPanel component using the CSS Grid trick for height animation:
- Use display: grid with gridTemplateRows
- Closed state: gridTemplateRows: "0fr" (collapses to 0 height)
- Open state: gridTemplateRows: "1fr" (expands to content height)
- Wrap children in a div with overflow: hidden
- Add opacity fade: 0 when closed, 1 when open
- Transition: "grid-template-rows 0.25s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.2s ease"
COLLAPSE GAP TRICK:
If parent uses gap/space between items, add negative margin when closed to collapse the gap:
- marginTop: isOpen ? undefined : "-12px" (adjust based on your gap size)
- Animate the margin too for smooth effect
PREVENT SCROLLBAR LAYOUT SHIFT:
Add to your global CSS on html element:
- scrollbar-gutter: stable (reserves space for scrollbar)
- overflow-x: hidden (prevents horizontal scroll)
This creates buttery smooth expand/collapse without the jarring height jump or scrollbar layout shift.git add .
git commit -m "Add themes and smooth animations"Let users attach an image to their polished tweets. UploadThing handles the file upload, with a pattern that previews locally first and uploads only when saving, so no orphaned files are left if users change their mind:
bun add uploadthing @uploadthing/reactpnpm add uploadthing @uploadthing/reactyarn add uploadthing @uploadthing/reactnpm install uploadthing @uploadthing/react- Go to uploadthing.com and create an account
- Create a new app in the dashboard
- Copy your
UPLOADTHING_TOKENand add it to your.env:
UPLOADTHING_TOKEN=your_token_hereAdd image upload to tweets using UploadThing.
Requirements:
1. Users should be able to attach ONE image to their tweet
2. The image should only be uploaded to UploadThing when clicking "Save" (not when selecting the image)
3. While editing, show a local preview using URL.createObjectURL() - this avoids orphaned uploads if the user changes their mind
4. Show upload progress in the Save button ("Uploading..." → "Saving...")
5. Allow removing the selected image before saving (X button on the image preview)
6. Display saved images in the tweet library/cards
Implementation steps:
1. Create UploadThing FileRouter at app/api/uploadthing/core.ts with a "tweetImage" route (4MB max, 1 file)
2. Create the route handler at app/api/uploadthing/route.ts
3. Create typed utilities at app/lib/uploadthing.ts with useUploadThing hook
4. Update TweetPreview component:
- Add file state (File object) and preview URL state
- Add hidden file input + "Add image" label/button
- Show image preview with remove button
- In handleSave: if file exists, call startUpload() first, then save tweet with the returned URL
5. Update SavedTweetCard to display imageUrl if present
The schema already has imageUrl and imageAlt fields.
Key pattern: Store File locally → preview with createObjectURL → upload only on save → save URL to database- Transform a tweet
- Click "Add image" and select a photo
- Verify the preview appears with an X button to remove
- Click Save and watch the button states: "Uploading..." → "Saving..." → "Saved!"
- Open the Library and verify the image appears with the saved tweet
git add .
git commit -m "Add image upload with UploadThing"You've built a tweet polishing application with:
- ✅ Local AI with Ollama (no API keys)
- ✅ Customizable filters and context
- ✅ Three dark themes
- ✅ Cloud database with Prisma Postgres
- ✅ Image uploads with UploadThing
- ✅ Animated panels
Here are some ideas to extend your app:
- Add user authentication with Clerk
- Add multiple LLM model options
- Implement tweet scheduling
- Add analytics to track transformations
- Create shareable public links