# Make a Lovable App Look Less Generic: Swap In 3 Components

> Clone your Lovable project through GitHub sync, install new components with npx shadcn add <registry url>, run tsc as well as the build, and push. New Lovable projects render on the server, so never read localStorage during render. Add a project knowledge note so later prompts keep the swap.

- URL: https://qodesign.dev/blog/make-lovable-app-look-less-generic-swap-components
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: AI-built UI
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

A Lovable app looks generic for the same reason most AI-built apps do: the theme toggle, the pricing tabs and the toasts are the stock pieces every other app ships with. The quickest fix I've found is to swap three of those pieces for components with a bit of character, install them through GitHub sync with the shadcn CLI, and add one line of project knowledge so Lovable keeps them. On a new Lovable project there are two traps on the way, both caused by server-side rendering, and both covered below.

I make [qodesign](/), so the three components here are my free ones: the Day/Night Toggle, Pricing Switch and Toast Stack from the MIT-licensed [qodesign-free registry](/free). The steps work the same for any shadcn registry component. I tested everything in a fresh TanStack Start app with Tailwind v4 and shadcn/ui 4.22, ran strict `tsc` and `vite build`, and clicked through the built app in headless Chromium. I didn't run the Lovable side of the round trip in this test, so the Lovable steps below come from Lovable's own docs, linked where they appear.

## What changed under new Lovable projects

Lovable's docs say [TanStack Start became the default for new projects on May 13, 2026](https://docs.lovable.dev/features/upgrade-to-tanstack-start). Lovable's [post about the switch](https://lovable.dev/blog/building-apps-using-tanstack-start) explains the reason: pages now render on the server, so crawlers and link previews get real HTML instead of an empty shell. Older projects stay on React + Vite unless you upgrade them.

That matters for this job. Any component that touches `window` or `localStorage` while it renders now runs on a server first, where neither exists. Plenty of copy-paste theme toggles do exactly that.

## Why route the swap through GitHub

You can paste a component into the chat and ask Lovable to add it. That costs build credits, and the free plan gives you [5 a day, up to 30 a month](https://lovable.dev/pricing). The built-in code editor is [read-only on the free plan](https://docs.lovable.dev/features/code-mode), so you can't paste files in yourself either.

GitHub sync is on every plan for github.com. Per Lovable's [Git sync docs](https://docs.lovable.dev/integrations/git-sync-overview), linking a project creates a repository, and commits you push to the synced branch show up in your Lovable project. So the plan is: connect GitHub, clone, run the shadcn CLI locally, check the build, push.

```bash
git clone https://github.com/you/your-lovable-app.git
cd your-lovable-app
npm install
```

If the repo has a `components.json` at the root, it's set up for shadcn/ui and the commands below work as written. If it doesn't, run `npx shadcn@latest init` first.

## Install three components from a registry URL

```bash
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/day-night-toggle.json https://teodoravadani.github.io/qodesign-free/r/pricing-switch.json https://teodoravadani.github.io/qodesign-free/r/toast-stack.json
```

The CLI wrote five files into `src/components` and added `framer-motion` to `package.json`:

```text
src/components/day-night-toggle/DayNightToggle.tsx
src/components/pricing-switch/PricingSwitch.tsx
src/components/pricing-switch/pricing-switch.css
src/components/toast-stack/ToastStack.tsx
src/components/toast-stack/toast-stack.css
```

Those files are now yours. Nothing auto-updates them, and Lovable can read them like any other file in the project. If you want to look at a registry item before installing it, open its JSON URL in the browser. My [post on installing from third-party registries](/blog/shadcn-registry-install-third-party-components) has a checklist for that.

### Fix the first type error before you push

Strict `tsc` failed on the copies I installed on October 21, 2026. If your copies already use `import type` for `ReactNode`, skip this step.

```text
src/components/pricing-switch/PricingSwitch.tsx(4,3): error TS1484: 'ReactNode' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
src/components/toast-stack/ToastStack.tsx(6,3): error TS1484: 'ReactNode' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
```

The TanStack Start template turns on `verbatimModuleSyntax`, which wants types imported as types. `vite build` passed anyway, because it strips types without checking them, so this only shows up when you run `tsc`. The fix is one word in each file:

```tsx
import React, {
  type ReactNode,
```

After that change, `tsc --noEmit` exited with 0 errors.

## Swap 1: a theme toggle that survives server rendering

This is the version a quick prompt tends to produce:

```tsx
// the version a chat prompt often produces: reads localStorage during render
const [night, setNight] = useState(() => localStorage.getItem("theme") === "dark")
```

On a server-rendered route that line throws. In my test the server logged `ReferenceError: localStorage is not defined` and the browser got a page with nothing in it. The fix splits the job in two: a tiny inline script sets the `dark` class before the page paints, and the component reads that class after it mounts.

`src/components/theme-toggle.tsx`:

```tsx
import { useEffect, useState } from "react"
import DayNightToggle from "@/components/day-night-toggle/DayNightToggle"

export function ThemeToggle() {
  // false on the server and on the first client render, so the markup matches
  const [night, setNight] = useState(false)

  useEffect(() => {
    // the inline script in __root.tsx already set the class before paint
    setNight(document.documentElement.classList.contains("dark"))
  }, [])

  return (
    <DayNightToggle
      width={72}
      night={night}
      label="Dark mode"
      onChange={(next) => {
        setNight(next)
        document.documentElement.classList.toggle("dark", next)
        try {
          localStorage.setItem("theme", next ? "dark" : "light")
        } catch {
          // private mode or storage blocked: the toggle still works for this visit
        }
      }}
    />
  )
}
```

Then in `src/routes/__root.tsx`, add the inline script to `head`, put `suppressHydrationWarning` on `<html>` (the script changes its class before React hydrates), and mount the toaster for swap 3:

```tsx
export const Route = createRootRoute({
  head: () => ({
    // ...meta and links as generated
    scripts: [
      {
        // runs before the body paints, so a returning dark-mode visitor never sees a white flash
        children: `try{if(localStorage.getItem("theme")==="dark")document.documentElement.classList.add("dark")}catch(e){}`,
      },
    ],
    // ...
  }),
  // ...
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Toaster />
        <Scripts />
      </body>
    </html>
  )
}
```

What I checked in the built app: the toggle starts unchecked on the server and in the first client render, so React logged no hydration warnings. Clicking it set `aria-checked="true"`, added the `dark` class and stored `theme=dark`. After a reload the `dark` class was already on `<html>` when the DOM finished loading, before React ran, so there was no white flash. Space toggles it from the keyboard.

One thing I found while tabbing through the page: the first copy of the toggle I installed had no visible focus ring. Its inset shadow is an inline style, and an inline `box-shadow` beats Tailwind's ring classes, which are box-shadows too. I fixed the registry the same day, and a fresh install now draws keyboard focus as a purple outline that scales with `width`. If tabbing to your copy shows no ring, run the add command above again. If you add any animated component with a custom shadow, tab to it once and look.

For the same flash problem in a Next.js app, see [fixing the dark mode flash with Tailwind](/blog/nextjs-dark-mode-flash-tailwind). The toggle has its own page at [/react/day-night-theme-toggle](/react/day-night-theme-toggle).

## Swap 2: replace the pricing tabs

The stock pricing section is usually a Tabs component with "Monthly" and "Yearly" and three cards that change a number. Pricing Switch keeps the same idea and adds a pill that stretches between the two options, prices that roll like an odometer and a highlighted featured plan. It takes a plain array of plans, so the data stays yours. Monthly and Yearly change the numbers on the same cards rather than opening a new panel, and [Accessible Animated Tabs in React and Tailwind CSS Without a Library](/blog/accessible-animated-tabs-react-tailwind-no-library) explains why that makes tab roles the wrong fit for a switch like this.

`src/routes/index.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router"
import { PricingSwitch, type Plan } from "@/components/pricing-switch/PricingSwitch"
import { toast } from "@/components/toast-stack/ToastStack"
import { ThemeToggle } from "@/components/theme-toggle"

export const Route = createFileRoute("/")({ component: Home })

const plans: Plan[] = [
  {
    id: "starter",
    name: "Starter",
    description: "For a side project that just launched.",
    monthly: 9,
    yearly: 7,
    cta: "Start free",
    features: ["1 project", "Email support"],
  },
  {
    id: "pro",
    name: "Pro",
    description: "For apps with paying users.",
    monthly: 29,
    yearly: 24,
    cta: "Go Pro",
    features: ["10 projects", "Priority support", "Custom domain"],
    featured: true,
  },
  {
    id: "team",
    name: "Team",
    note: "up to 5 seats",
    description: "For a small team shipping together.",
    monthly: 79,
    yearly: 66,
    cta: "Talk to us",
    features: ["Unlimited projects", "Shared billing"],
  },
]

function Home() {
  return (
    <main className="min-h-svh bg-background text-foreground">
      <header className="flex items-center justify-between p-6">
        <span className="font-medium">Acme</span>
        <ThemeToggle />
      </header>
      <PricingSwitch
        plans={plans}
        header={<h1 className="text-3xl font-semibold">Pricing</h1>}
        onSelect={(plan, yearly) => {
          toast.promise(
            new Promise((resolve) => setTimeout(resolve, 900)),
            {
              loading: { title: `Starting ${plan.name}` },
              success: () => ({
                title: `${plan.name} selected`,
                description: yearly ? "Billed yearly" : "Billed monthly",
              }),
            },
          )
        }}
      />
    </main>
  )
}
```

In the built app, the Monthly/Yearly control is a pair of radio buttons with `aria-checked`. Choosing Yearly changed the written-out price on each card to "$7 per month, $84 billed yearly", "$24 per month, $288 billed yearly" and "$66 per month, $792 billed yearly", so the price is in the page text as well as in the rolling digits. At 390 px wide the page had no sideways scroll. More on the component at [/react/pricing-toggle-monthly-yearly](/react/pricing-toggle-monthly-yearly).

## Swap 3: toasts with a promise state

`<Toaster />` sits in the root layout from swap 1, and `toast` is a plain function you can call from anywhere. In the pricing example, pressing "Go Pro" shows a spinner toast ("Starting Pro") that turns into a check with "Pro selected / Billed yearly" when the promise settles. Both texts appeared in the built app, in that order. If your Lovable app already uses another toast library, remove its `<Toaster />` from the root when you add this one, so you don't end up with two stacks. Details are on [/react/toast-stack](/react/toast-stack). If you build in Next.js instead and the toast has to follow a server action, see [Toast Notifications After a Next.js Server Action](/blog/toast-notifications-nextjs-server-actions).

## Push, then tell Lovable to keep them

```bash
npx tsc --noEmit
npm run build
git add -A
git commit -m "Swap theme toggle, pricing and toasts"
git push
```

According to Lovable's docs, the commit then appears in your project, and synced commits that change dependencies go through Lovable's dependency audit. The sync card in Project settings → Git tells you whether Lovable and GitHub are on the same commit.

The last step stops the next prompt from undoing your work. Project knowledge is a set of standing instructions for Lovable's agent. The [knowledge docs](https://docs.lovable.dev/features/knowledge) say it lives in Project settings → Knowledge, with room for 10,000 characters. Something like this is enough:

```text
UI components:
- Theme switching uses src/components/theme-toggle.tsx (wraps DayNightToggle). Don't replace it with a Switch.
- Pricing uses PricingSwitch from src/components/pricing-switch. Add plans to the plans array instead of building new cards.
- Notifications use toast() and <Toaster /> from src/components/toast-stack. Don't add another toast library.
- Never read window or localStorage during render. Do it in useEffect.
```

The last rule is worth keeping even if you skip the rest, because it's the server-rendering trap from swap 1 in one line.

## A short checklist

- Clone through GitHub sync and run `npx shadcn@latest add <registry url>` locally.
- Run `tsc --noEmit` as well as the build. The build alone missed the type errors.
- Keep `localStorage` and `window` out of render. Set the theme class with an inline head script.
- Tab through every new component and check that focus is visible.
- Remove the old component's provider (a second `<Toaster />` is the common one).
- Add a project knowledge note so Lovable keeps the swap.

If you're picking where components come from in the first place, [choosing a React + Tailwind component library](/blog/choose-react-tailwind-component-library-2026) compares the options, and [qodesign for Lovable](/for/lovable) covers how the paid pieces fit a Lovable project. They're React + TypeScript with Tailwind (most also use Framer Motion). For v0 apps the same idea is in [making a v0 app not look AI-generated](/blog/make-v0-app-not-look-ai-generated).

## FAQ

### How do I make my Lovable app look less generic?

Replace the stock pieces people see first, usually the theme toggle, the pricing section and the notifications, with components that have their own motion and layout. Install them through GitHub sync with `npx shadcn@latest add <registry url>`, then add a project knowledge note so later prompts keep them.

### Can I add shadcn components to Lovable on the free plan?

Yes, through GitHub. Lovable's code editor is read-only on the free plan, but GitHub sync for github.com is on every plan. Clone the repo, run the shadcn CLI locally and push. Lovable's docs say commits pushed to the synced branch appear in the project.

### Why does my theme toggle break after Lovable moved to TanStack Start?

New Lovable projects render on the server. Code that reads `localStorage` or `window` during render runs where neither exists and throws `localStorage is not defined`. Read storage in `useEffect`, and set the initial theme class with a small inline script in the document head.

### Will Lovable overwrite components I add through GitHub?

Synced commits become part of the project, and Lovable edits them like any other file when a prompt asks it to. To keep them, say so in Project settings → Knowledge, for example "Notifications use toast() from src/components/toast-stack. Don't add another toast library."
