Neural Tech Daily
ai-tutorials

Deploy a Next.js LLM app with auth: Supabase + Anthropic + Vercel, end-to-end

Bootstrap Next.js 15 App Router, add Supabase email magic-link auth, gate /chat behind a session, stream Claude responses, and deploy to Vercel free tier.

Updated ~10 min read
Share
Next.js App Router installation documentation page at nextjs.org showing the create-next-app CLI command and default configuration

Image: Next.js Installation docs, used for editorial coverage of the framework this tutorial deploys.

What you’ll build

By the end of this tutorial you will have a production-shaped LLM SaaS scaffold: a Next.js 15 App Router project with email magic-link sign-in via Supabase, a /chat route that only renders for an authenticated session, a server-side API route that streams Claude responses over server-sent events, and a live deployment on the Vercel Hobby tier. The total scaffold runs under roughly 300 lines of TypeScript across six files.

The architecture is the one Anthropic and Supabase docs both nudge toward: a Next.js App Router frontend, Supabase as the auth + database boundary, a Next.js Route Handler as the server-side proxy to Anthropic, and Vercel as the hosting target. The Route Handler keeps the Anthropic API key off the client, and Supabase middleware refreshes the auth cookie on every request so server components see the live session.

This walkthrough targets Next.js 16.2 (the App Router default since Next.js 15), the @supabase/ssr package (Supabase’s canonical server-side auth helper), and @anthropic-ai/sdk for the Messages API. Costs to follow along: zero for Supabase and Vercel on free tiers, and well under one US dollar of Anthropic API usage at the model picked below.

What you’ll need

  • Node.js 20.9 or later (the minimum Next.js 16 supports 1 ).
  • A Supabase account and a new project (free tier is sufficient).
  • An Anthropic API key with at least $1 of credit on the Console.
  • A Vercel account linked to your GitHub.
  • A GitHub repository for the project.
  • Roughly 90 minutes start to finish: 15 to bootstrap, 25 for auth, 20 for the chat route, 15 for streaming, 15 for deployment.

Step 1: Bootstrap Next.js 15 App Router

Spin up a fresh App Router project with the canonical CLI:

npx create-next-app@latest llm-app --yes
cd llm-app

The --yes flag accepts the recommended defaults per the Next.js docs: TypeScript, ESLint, Tailwind CSS, App Router, Turbopack, and the @/* import alias. The created folder includes an app/ directory with layout.tsx and page.tsx, a package.json wired to next dev / next build / next start, and a default .gitignore. Run the dev server to confirm:

npm run dev

Visit http://localhost:3000 and the default Next.js landing page should render. Stop the server with Ctrl-C before installing the next set of dependencies.

Step 2: Install Supabase and Anthropic SDKs

Two SDKs handle the heavy lifting: @supabase/ssr for server-side auth in the App Router, and @anthropic-ai/sdk for the Messages API client.

npm install @supabase/supabase-js @supabase/ssr @anthropic-ai/sdk

The @supabase/ssr package supersedes the older @supabase/auth-helpers-nextjs for App Router projects, per the Supabase migration guide. The two new functions are createBrowserClient (for client components running in the browser) and createServerClient (for server components, server actions, and route handlers).

Create a .env.local file at the project root:

NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY
ANTHROPIC_API_KEY=sk-ant-api03-...

Find the Supabase values under Project Settings → API in the Supabase dashboard. The NEXT_PUBLIC_ prefix exposes the variable to the browser bundle; the ANTHROPIC_API_KEY stays server-only because it’s never prefixed. Add .env.local to .gitignore if it isn’t already (the default create-next-app template handles this).

Supabase server-side auth documentation page for Next.js describing the @supabase/ssr package and the createBrowserClient / createServerClient helpers

Image: Supabase server-side auth docs for Next.js, used for editorial coverage of the auth package this tutorial wires.

Step 3: Wire up the Supabase SSR client

The Supabase docs walk through three small helper files: a browser client, a server client, and a middleware that refreshes the auth cookie on every request. Create them under lib/supabase/.

lib/supabase/client.ts:

import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

lib/supabase/server.ts:

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Called from a Server Component; safe to ignore
            // when middleware is refreshing sessions.
          }
        },
      },
    }
  );
}

middleware.ts at the project root (alongside app/):

import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          response = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  await supabase.auth.getUser();
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

The middleware’s only job is to call supabase.auth.getUser() once per request, which refreshes the access token cookie when it’s about to expire. Without it, server components fall out of sync with the auth state.

Supabase passwordless email logins documentation page describing the signInWithOtp method and the magic-link default behaviour

Image: Supabase passwordless email logins docs, used for editorial coverage of the magic-link flow this tutorial uses.

Per the Supabase passwordless-email docs, signInWithOtp with an email address sends a magic link by default. Create app/login/page.tsx:

'use client';

import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [status, setStatus] = useState<'idle' | 'sent' | 'error'>('idle');
  const supabase = createClient();

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    const { error } = await supabase.auth.signInWithOtp({
      email,
      options: {
        emailRedirectTo: `${window.location.origin}/auth/callback`,
      },
    });
    setStatus(error ? 'error' : 'sent');
  }

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="text-2xl font-bold">Sign in</h1>
      <form onSubmit={onSubmit} className="mt-4 flex flex-col gap-3">
        <input
          type="email"
          required
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="you@example.com"
          className="rounded border px-3 py-2"
        />
        <button type="submit" className="rounded bg-black px-4 py-2 text-white">
          Email me a magic link
        </button>
      </form>
      {status === 'sent' && (
        <p className="mt-4">Check your inbox for the sign-in link.</p>
      )}
      {status === 'error' && (
        <p className="mt-4 text-red-600">Something went wrong; try again.</p>
      )}
    </main>
  );
}

The magic link lands the user back at /auth/callback, which exchanges the URL code for a session cookie. Create app/auth/callback/route.ts:

import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');

  if (code) {
    const supabase = await createClient();
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(`${origin}/chat`);
}

The redirect target on success is /chat, which is the gated route built next.

Step 5: Gate /chat behind an authenticated session

Server components can read the session directly via the server client. If user is null, redirect to /login. Create app/chat/page.tsx:

import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import ChatUI from './ChatUI';

export default async function ChatPage() {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect('/login');
  }

  return (
    <main className="mx-auto max-w-2xl p-8">
      <header className="mb-6 flex items-center justify-between">
        <h1 className="text-xl font-semibold">Chat</h1>
        <span className="text-sm text-gray-500">{user.email}</span>
      </header>
      <ChatUI />
    </main>
  );
}

The redirect() call short-circuits the render. An unauthenticated user never sees the chat UI markup; the response is a 307 to /login. The session check runs server-side, so disabling JavaScript or tampering with client state doesn’t bypass it.

Anthropic Messages API streaming documentation page describing server-sent events and the TypeScript SDK stream pattern with text deltas

Image: Anthropic streaming Messages docs, used for editorial coverage of the SSE pattern this tutorial implements.

Step 6: Build the Anthropic streaming API route

The API route is the only place the Anthropic key is read. It accepts a POST with the user message, opens a streaming request against the Messages API, and pipes server-sent events back to the browser.

Per the Anthropic streaming docs, setting stream: true on the Messages API returns SSE events; the TypeScript SDK exposes .stream(...) with a .on('text', ...) handler that yields text deltas as they arrive. Create app/api/chat/route.ts:

import Anthropic from '@anthropic-ai/sdk';
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

const anthropic = new Anthropic();

export async function POST(request: Request) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { message } = await request.json();

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      const claudeStream = anthropic.messages.stream({
        model: 'claude-sonnet-4-5',
        max_tokens: 1024,
        messages: [{ role: 'user', content: message }],
      });

      claudeStream.on('text', (text) => {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
      });

      claudeStream.on('end', () => {
        controller.enqueue(encoder.encode('data: [DONE]\n\n'));
        controller.close();
      });

      claudeStream.on('error', (err) => {
        controller.error(err);
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

The route re-checks the Supabase session on every request. A stolen client-side token can’t reach Anthropic without a valid Supabase cookie. The SSE response uses the text/event-stream content type per the MDN spec, which lets the browser’s EventSource API consume it natively.

app/chat/ChatUI.tsx consumes the stream:

'use client';

import { useState } from 'react';

export default function ChatUI() {
  const [input, setInput] = useState('');
  const [reply, setReply] = useState('');
  const [busy, setBusy] = useState(false);

  async function send() {
    setReply('');
    setBusy(true);
    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: input }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split('\n\n');
      buffer = parts.pop() ?? '';
      for (const part of parts) {
        if (!part.startsWith('data: ')) continue;
        const payload = part.slice(6);
        if (payload === '[DONE]') continue;
        const { text } = JSON.parse(payload);
        setReply((prev) => prev + text);
      }
    }
    setBusy(false);
  }

  return (
    <div>
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        rows={3}
        className="w-full rounded border p-2"
        placeholder="Ask Claude anything"
      />
      <button
        onClick={send}
        disabled={busy || !input}
        className="mt-2 rounded bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        {busy ? 'Streaming...' : 'Send'}
      </button>
      <pre className="mt-4 whitespace-pre-wrap rounded bg-gray-50 p-4">{reply}</pre>
    </div>
  );
}

Run npm run dev, visit http://localhost:3000/login, sign in via the magic link, and the chat UI streams Claude tokens as they arrive.

Vercel homepage open-graph image showing the Vercel wordmark and the platform branding used as the hosting target in this tutorial

Image: Vercel homepage open-graph asset, used for editorial coverage of the hosting platform this tutorial deploys to.

Step 7: Deploy to Vercel

Push the repo to GitHub, then import it on Vercel’s dashboard. Per the Vercel deployments docs, the standard flow is: connect the Git repo, Vercel detects Next.js, and every push to main triggers a production deployment.

Add the three environment variables in the Vercel project settings (Settings → Environment Variables): NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and ANTHROPIC_API_KEY. Vercel encrypts each at rest and exposes them only to the build and runtime processes that opt in.

Back in Supabase, open Authentication → URL Configuration and add the Vercel production URL to the redirect allowlist (for example, https://your-app.vercel.app/auth/callback). Without this, the magic-link callback bounces.

Trigger the first deployment by pushing to main. Vercel runs next build, ships the output to its edge network, and assigns a .vercel.app URL. Visit /login, sign in, and confirm the chat route streams in production.

Vercel free-tier limits worth knowing

The Hobby plan covers personal projects with these published ceilings per month 2 :

  • 1,000,000 function invocations.
  • 100 GB-hours of function duration.
  • 1,000,000 edge requests.
  • 4 CPU-hours active compute and 360 GB-hours provisioned memory.
  • 200 projects, 100 deployments per day, 50 domains per project.
  • 10-second default function duration, configurable up to 60 seconds.

Two restrictions matter for an LLM SaaS scaffold. First, the Hobby plan’s fair-use policy restricts commercial use to non-commercial personal projects; revenue-generating apps must upgrade to Pro. Second, the default 10-second function timeout will cut off long Claude responses; raise it via the maxDuration export in app/api/chat/route.ts:

export const maxDuration = 60;

Pro tier lifts the configurable ceiling to 300 seconds. Anthropic streaming returns tokens incrementally, so a 60-second budget typically covers single-turn chat at Sonnet-class throughput; multi-turn or long-document workloads should plan for Pro or a different host.

Cost framing

Anthropic publishes current per-million-token pricing on its API pricing page. A single chat round-trip at roughly 200 input tokens and 400 output tokens lands under one US cent at Sonnet-class rates; budget under $5 of Anthropic credit for a relaxed development session including a few hundred test prompts. Verify the live tier on the pricing page before any deployment decision, since per-token pricing drifts with model releases.

Supabase free tier covers a single project with 500 MB database storage and unlimited auth users; the magic-link flow uses Supabase’s hosted email sender, which has its own daily limits documented on the Supabase dashboard.

What’s next

The scaffold above is a working starting point, not a finished product. The natural extensions, in roughly increasing order of effort:

  • Persist conversation history in a Supabase Postgres table, scoped by user_id row-level-security policies, so the chat survives a page refresh.
  • Replace signInWithOtp with signInWithOAuth if you want Google or GitHub social login; the Supabase docs cover the provider configuration per OAuth source.
  • Add rate limiting on /api/chat via a Supabase Edge Function or Vercel KV counter, since the route currently lets an authenticated user hammer Anthropic at full Hobby-tier function quota.
  • Wrap the Anthropic call with Anthropic’s tool-use schema if you want the LLM to call back into your app’s data layer.
  • Move to Pro tier on Vercel when commercial use kicks in, per Vercel’s fair-use guidelines.

The codified pattern, though, holds: Supabase owns identity, Vercel owns hosting, Anthropic owns reasoning, and the Next.js Route Handler keeps each provider’s secret on its own side of the wire.

How this article was made: an autonomous AI pipeline researched, drafted, fact-checked, and reviewed this piece, aggregating publicly-available information from the sources consulted below. AI (artificial intelligence) can make mistakes, so please cross-check the consulted sources before acting on anything here. Neural Tech Daily is not liable for decisions or outcomes based on this article.

Sources consulted

Anonymous · no cookies set

Report a problem with this article

Articles are produced by an autonomous AI pipeline; mistakes do happen. Tell us what's wrong and the editorial review will revisit the claim.

Category

Found this useful? Share it.