Skip to main content
Prisma Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

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:

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:

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:4b

You should see a progress indicator like "pulling manifest…95%". This downloads approximately 3.3GB.

Test that the model responds:

ollama run gemma3:4b

Type something and confirm it responds. Press Ctrl+C to exit.

You can also verify the API is accessible:

curl http://localhost:11434/api/tags

You 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:

title="bun"
bunx create-next-app@latest tweetsmith
pnpm
pnpm dlx create-next-app@latest tweetsmith
yarn
yarn dlx create-next-app@latest tweetsmith
npm
npx create-next-app@latest tweetsmith

When 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 tweetsmith

Start your development server to verify everything works:

title="bun"
bun run dev
pnpm
pnpm run dev
yarn
yarn dev
npm
npm run dev

Open 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.
  1. Restart your dev server if needed
  2. You should see a dark-themed page with a textarea and transform button
  3. Type something and verify the character counter updates
  4. 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.
  1. Make sure Ollama is running in the background
  2. Type a draft tweet like "just shipped a new feature, its pretty cool i think"
  3. Click Transform
  4. 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:

title="bun"
bun add lucide-react
pnpm
pnpm add lucide-react
yarn
yarn add lucide-react
npm
npm install lucide-react

Then 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.
  1. Open the Filters panel and adjust the character limit
  2. Toggle between emoji modes
  3. Add some context like "Tech founder, casual tone"
  4. 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.

title="bun"
bun add prisma@prev tsx --dev

bun add @prisma/adapter-pg @prisma/client@7 dotenv
pnpm
pnpm add prisma@prev tsx --save-dev
pnpm add @prisma/adapter-pg @prisma/client@7 dotenv
yarn
yarn add prisma@prev tsx --dev
yarn add @prisma/adapter-pg @prisma/client@7 dotenv
npm
npm install prisma@prev tsx --save-dev
npm install @prisma/adapter-pg @prisma/client@7 dotenv
title="bun"
bunx --bun prisma init --output ../app/generated/prisma

bunx create-db
pnpm
pnpm prisma init --output ../app/generated/prisma
pnpm dlx create-db
yarn
yarn prisma init --output ../app/generated/prisma
yarn dlx create-db
npm
npx prisma init --output ../app/generated/prisma
npx create-db

Replace 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 prisma

Update 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()
title="bun"
bunx prisma db push

bunx prisma generate

bun run db:test
pnpm
pnpm prisma db push
pnpm prisma generate
pnpm run db:test
yarn
yarn prisma db push
yarn prisma generate
yarn db:test
npm
npx prisma db push
npx prisma generate
npm run db:test

You should see success messages. Open Prisma Studio to view your data:

title="bun"
bun run db:studio
pnpm
pnpm run db:studio
yarn
yarn db:studio
npm
npm run db:studio

Copy 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/tweets
git 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.
  1. Save a few transformed tweets
  2. Click the Library button in the top-right
  3. Verify your saved tweets appear in the panel
  4. Click a tweet to load it back into the draft
  5. 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 elements

The 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:

title="bun"
bun add uploadthing @uploadthing/react
pnpm
pnpm add uploadthing @uploadthing/react
yarn
yarn add uploadthing @uploadthing/react
npm
npm install uploadthing @uploadthing/react
  1. Go to uploadthing.com and create an account
  2. Create a new app in the dashboard
  3. Copy your UPLOADTHING_TOKEN and add it to your .env:
UPLOADTHING_TOKEN=your_token_here
Add 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
  1. Transform a tweet
  2. Click "Add image" and select a photo
  3. Verify the preview appears with an X button to remove
  4. Click Save and watch the button states: "Uploading..." → "Saving..." → "Saved!"
  5. 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
Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu