DEV Community

Cover image for Wiring auth + Stripe + streaming AI chat into one Next.js starter (what I learned)
SurfingCoder
SurfingCoder

Posted on

Wiring auth + Stripe + streaming AI chat into one Next.js starter (what I learned)

Wiring auth + Stripe + streaming AI chat into one Next.js starter

Every time I started an AI side project, I rebuilt the same four things before I could write a single interesting line: authentication, Stripe subscriptions, a streaming chat UI, and usage limits so the API bill wouldn't explode.

So I finally sat down and wired them into one Next.js 16 starter. Here's how the interesting pieces fit together - and the gotchas that cost me time, so they don't cost you.

Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 · shadcn/ui · Better Auth · Drizzle + Postgres · Vercel AI SDK · Stripe.


1. Auth you actually own: Better Auth

I wanted auth without a vendor lock-in or a monthly bill. Better Auth is fully self-hosted and plays clean with Drizzle:

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/db";
import * as schema from "@/db/auth-schema";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg", schema }),
  emailAndPassword: { enabled: true },
  user: {
    additionalFields: {
      stripeCustomerId: { type: "string", required: false },
      plan: { type: "string", required: false, defaultValue: "free" },
    },
  },
  plugins: [nextCookies()],
});
Enter fullscreen mode Exit fullscreen mode

Those additionalFields (plan, stripeCustomerId) are what tie billing to the user later.

Protecting routes is a tiny middleware - it just checks for the session cookie at the edge:

// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export function middleware(req: NextRequest) {
  if (!getSessionCookie(req)) {
    return NextResponse.redirect(new URL("/sign-in", req.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/chat/:path*", "/billing/:path*", "/account/:path*"] };
Enter fullscreen mode Exit fullscreen mode

Tip: generate the Drizzle schema from your config with npx @better-auth/cli generate - it's the source of truth, and it picks up your additionalFields automatically.


2. Streaming AI chat with the Vercel AI SDK (+ a v7 gotcha)

The chat endpoint streams tokens back and persists the conversation. The route is short:

// src/app/api/chat/route.ts
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, convertToModelMessages, type UIMessage } from "ai";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: anthropic("claude-haiku-4-5"), // swap any provider
    system: "You are a helpful AI assistant.",
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}
Enter fullscreen mode Exit fullscreen mode

The gotcha that cost me 20 minutes: in AI SDK v7, convertToModelMessages is async. If you forget the await, TypeScript throws a cryptic overload error because you're handing streamText a Promise<ModelMessage[]> instead of an array. Await it.

On the client, useChat seeds initial history and renders message parts:

"use client";
import { useChat } from "@ai-sdk/react";

const { messages, sendMessage, status } = useChat({ messages: initialMessages });
// each message: m.parts.map(p => p.type === "text" ? p.text : null)
Enter fullscreen mode Exit fullscreen mode

Markdown tip: the model returns markdown - render it with react-markdown + the Tailwind typography plugin, and tell the model to format in markdown in your system prompt. CSS alone won't add bullets if the model emits plain line breaks.


3. The piece most AI boilerplates skip: usage limits

AI costs money per message. If free users are unlimited, a boilerplate is a trap. Gate it:

// in the chat route, before streaming
import { FREE_DAILY_MESSAGE_LIMIT, isPro } from "@/lib/plans";
import { and, count, eq, gte } from "drizzle-orm";

if (!isPro(plan)) {
  const since = new Date(); since.setHours(0, 0, 0, 0);
  const [c] = await db.select({ value: count() })
    .from(message)
    .where(and(eq(message.chatId, chatId), eq(message.role, "user"), gte(message.createdAt, since)));

  if (Number(c?.value ?? 0) >= FREE_DAILY_MESSAGE_LIMIT) {
    return new Response("Daily free limit reached. Upgrade for unlimited.", { status: 429 });
  }
}
Enter fullscreen mode Exit fullscreen mode

The client surfaces the 429 as a toast, and the chat UI shows X/10 messages today for free users. Simple, but it's the difference between a demo and something you'd actually run.


4. Stripe: flip the plan on webhook

Checkout creates a subscription session with the user id in metadata; the webhook flips their plan and stores the customer id so they can manage/cancel later via the billing portal:

if (event.type === "checkout.session.completed") {
  const s = event.data.object as Stripe.Checkout.Session;
  if (s.metadata?.userId) {
    await db.update(user)
      .set({ plan: "pro", stripeCustomerId: s.customer as string })
      .where(eq(user.id, s.metadata.userId));
  }
}
// customer.subscription.deleted -> set plan back to "free"
Enter fullscreen mode Exit fullscreen mode

Tip: test locally with stripe listen --forward-to localhost:3000/api/stripe/webhook.


Lessons

  • Next 16's cacheComponents flag breaks prerendering of auth-gated pages (they read headers()). I turned it off for predictable behavior.
  • Flexbox scroll bug: a chat message list won't scroll unless the flex ancestors have min-h-0. Classic, still bites everyone.
  • Env validation early (a tiny zod schema) turns "cryptic 3am crash" into "you forgot DATABASE_URL."

If you'd rather not wire all this yourself

I packaged everything above - plus a dashboard shell, dark mode, saved chat history, and the billing portal - into a starter kit so you can skip straight to building your actual product.

It's 50% off this launch week: Next.js AI SaaS Starter Kit

Either way - hope the snippets save you some time. Happy shipping. 🏄

- SurfingCoder

Top comments (1)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

the convertToModelMessages gotcha is worth the post alone. that async change in v7 throws a genuinely confusing overload error when you forget the await. it costs everyone 20 minutes, usually at 3am.