AI-Native Full Stack: Next.js 15 + Vercel AI SDK
Build production-ready AI-powered web applications with Next.js 15, Vercel AI SDK, Supabase, Shadcn/ui, Tailwind CSS 4, and Zod. Includes complete setup, AI streaming patterns, RAG implementation, and deployment best practices.
- Step 1
Project Scaffolding
Initialize a new Next.js 15 project with Turbopack and TypeScript. This sets up the modern Next.js App Router with React 19.
npx create-next-app@latest my-ai-app --typescript --tailwind --app --turbopack --no-src-dir cd my-ai-app - Step 2
Install Core Dependencies
Add the essential packages for the AI-native stack. The Vercel AI SDK provides streaming and tool-calling capabilities, Supabase handles backend services, Shadcn/ui provides accessible components, and Zod ensures type-safe validation.
npm install ai @ai-sdk/anthropic @ai-sdk/openai zod npm install @supabase/supabase-js @supabase/ssr npm install -D shadcn-ui @radix-ui/react-slot class-variance-authority clsx tailwind-merge - Step 3
Initialize Shadcn/ui
Set up Shadcn/ui component library with the new Tailwind CSS 4 JIT compiler. This creates the necessary configuration and utilities.
npx shadcn@latest init -y npx shadcn@latest add button card input textarea scroll-area⚠ Heads up: Ensure you select 'New York' style and Zinc as the base color when prompted during init. - Step 4
Upgrade to Tailwind CSS 4
Migrate to Tailwind CSS 4 which uses CSS-first configuration and improved JIT compilation. Replace the existing Tailwind config with the new CSS-based approach.
npm install tailwindcss@next @tailwindcss/postcss@next npm uninstall tailwindcss - Step 5
Configure Tailwind CSS 4
Create the new CSS-first configuration. Tailwind CSS 4 moves configuration from JS to CSS using
@themedirective.@import "tailwindcss"; @theme { /* Custom design tokens */ --color-primary: #3b82f6; --color-secondary: #8b5cf6; --font-sans: system-ui, -apple-system, sans-serif; --breakpoint-3xl: 1920px; } @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; } } - Step 6
Set Up Environment Variables
Create
.env.localfile with required API keys and configuration. Never commit this file to version control.# AI Provider Keys (choose one or both) ANTHROPIC_API_KEY=your_anthropic_key OPENAI_API_KEY=your_openai_key # Supabase Configuration NEXT_PUBLIC_SUPABASE_URL=your_project_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key SUPABASE_SERVICE_ROLE_KEY=your_service_role_key⚠ Heads up: The service role key bypasses Row Level Security. Only use it in API routes, never expose it to the client. - Step 7
Configure Supabase Client
Create type-safe Supabase clients for both client and server components. Next.js 15 requires careful handling of Server Components.
// lib/supabase/client.ts import { createBrowserClient } from '@supabase/ssr' import type { Database } from '@/types/database.types' export function createClient() { return createBrowserClient<Database>( 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' import type { Database } from '@/types/database.types' export async function createClient() { const cookieStore = await cookies() return createServerClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll() { return cookieStore.getAll() }, setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => { cookieStore.set(name, value, options) }) }, }, } ) } - Step 8
Initialize Supabase Project
Set up your Supabase project with essential tables for AI-powered features. This includes user management, chat history, and vector embeddings for RAG.
-- Enable required extensions create extension if not exists "uuid-ossp"; create extension if not exists "vector"; -- Chat conversations table create table conversations ( id uuid primary key default uuid_generate_v4(), user_id uuid references auth.users(id) on delete cascade, title text not null, created_at timestamp with time zone default now(), updated_at timestamp with time zone default now() ); -- Chat messages table create table messages ( id uuid primary key default uuid_generate_v4(), conversation_id uuid references conversations(id) on delete cascade, role text not null check (role in ('user', 'assistant', 'system')), content text not null, created_at timestamp with time zone default now() ); -- Vector embeddings for RAG create table embeddings ( id uuid primary key default uuid_generate_v4(), content text not null, embedding vector(1536), metadata jsonb, created_at timestamp with time zone default now() ); -- Enable Row Level Security alter table conversations enable row level security; alter table messages enable row level security; alter table embeddings enable row level security; -- RLS Policies create policy "Users can view own conversations" on conversations for select using (auth.uid() = user_id); create policy "Users can insert own conversations" on conversations for insert with check (auth.uid() = user_id); create policy "Users can view messages in own conversations" on messages for select using (exists ( select 1 from conversations where conversations.id = messages.conversation_id and conversations.user_id = auth.uid() )); -- Create indexes for performance create index messages_conversation_id_idx on messages(conversation_id); create index embeddings_embedding_idx on embeddings using ivfflat (embedding vector_cosine_ops); - Step 9
Create Zod Validation Schemas
Define type-safe schemas for your API routes and forms. Zod provides runtime validation that matches TypeScript types.
// lib/schemas/chat.ts import { z } from 'zod' export const messageSchema = z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().min(1).max(10000), }) export const chatRequestSchema = z.object({ messages: z.array(messageSchema), conversationId: z.string().uuid().optional(), temperature: z.number().min(0).max(2).default(0.7), maxTokens: z.number().min(1).max(4096).default(2048), }) export const ragQuerySchema = z.object({ query: z.string().min(1).max(500), topK: z.number().min(1).max(10).default(5), threshold: z.number().min(0).max(1).default(0.7), }) export type Message = z.infer<typeof messageSchema> export type ChatRequest = z.infer<typeof chatRequestSchema> export type RagQuery = z.infer<typeof ragQuerySchema> - Step 10
Create AI Chat API Route with Streaming
Implement a chat endpoint using the Vercel AI SDK's streaming capabilities. This provides real-time responses with proper error handling.
// app/api/chat/route.ts import { anthropic } from '@ai-sdk/anthropic' import { streamText } from 'ai' import { chatRequestSchema } from '@/lib/schemas/chat' import { createClient } from '@/lib/supabase/server' export const runtime = 'edge' export async function POST(req: Request) { try { const body = await req.json() const { messages, conversationId, temperature, maxTokens } = chatRequestSchema.parse(body) const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return new Response('Unauthorized', { status: 401 }) } // Verify conversation ownership if conversationId provided if (conversationId) { const { data: conversation } = await supabase .from('conversations') .select('user_id') .eq('id', conversationId) .single() if (!conversation || conversation.user_id !== user.id) { return new Response('Forbidden', { status: 403 }) } } // Stream the AI response const result = streamText({ model: anthropic('claude-3-5-sonnet-20241022'), messages, temperature, maxTokens, async onFinish({ text }) { // Save messages to database after streaming completes if (conversationId) { await supabase.from('messages').insert([ { conversation_id: conversationId, role: 'user', content: messages[messages.length - 1].content }, { conversation_id: conversationId, role: 'assistant', content: text }, ]) } }, }) return result.toDataStreamResponse() } catch (error) { console.error('Chat API error:', error) return new Response('Internal Server Error', { status: 500 }) } }⚠ Heads up: The edge runtime has limitations. For complex operations like vector embeddings, use the Node.js runtime instead. - Step 11
Create RAG Implementation
Build a Retrieval-Augmented Generation system using Supabase's vector database. This allows AI to answer questions based on your custom knowledge base.
// app/api/rag/query/route.ts import { anthropic } from '@ai-sdk/anthropic' import { generateText, embed } from 'ai' import { openai } from '@ai-sdk/openai' import { ragQuerySchema } from '@/lib/schemas/chat' import { createClient } from '@/lib/supabase/server' export async function POST(req: Request) { try { const body = await req.json() const { query, topK, threshold } = ragQuerySchema.parse(body) const supabase = await createClient() // Generate embedding for the query const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), value: query, }) // Search for similar documents const { data: documents, error } = await supabase.rpc( 'match_embeddings', { query_embedding: embedding, match_threshold: threshold, match_count: topK, } ) if (error) throw error // Build context from retrieved documents const context = documents .map((doc: any) => doc.content) .join('\n\n') // Generate response with context const { text } = await generateText({ model: anthropic('claude-3-5-sonnet-20241022'), messages: [ { role: 'system', content: `You are a helpful assistant. Use the following context to answer the user's question. If the answer cannot be found in the context, say so.\n\nContext:\n${context}`, }, { role: 'user', content: query, }, ], }) return Response.json({ answer: text, sources: documents.map((d: any) => ({ content: d.content, similarity: d.similarity, })), }) } catch (error) { console.error('RAG query error:', error) return new Response('Internal Server Error', { status: 500 }) } } - Step 12
Create Vector Similarity Search Function
Add a PostgreSQL function to perform efficient vector similarity search. This is required for the RAG implementation.
-- Create vector similarity search function create or replace function match_embeddings ( query_embedding vector(1536), match_threshold float, match_count int ) returns table ( id uuid, content text, metadata jsonb, similarity float ) language sql stable as $$ select embeddings.id, embeddings.content, embeddings.metadata, 1 - (embeddings.embedding <=> query_embedding) as similarity from embeddings where 1 - (embeddings.embedding <=> query_embedding) > match_threshold order by embeddings.embedding <=> query_embedding limit match_count; $$; - Step 13
Build AI Chat Component
Create a reusable chat interface component using Shadcn/ui and the Vercel AI SDK's React hooks. The
useChathook handles streaming, message management, and error states automatically.// components/chat.tsx 'use client' import { useChat } from 'ai/react' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' export function Chat() { const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/chat', }) return ( <Card className="w-full max-w-2xl mx-auto p-4"> <ScrollArea className="h-[500px] pr-4"> <div className="space-y-4"> {messages.map((message) => ( <div key={message.id} className={`flex ${ message.role === 'user' ? 'justify-end' : 'justify-start' }`} > <div className={`rounded-lg px-4 py-2 max-w-[80%] ${ message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted' }`} > {message.content} </div> </div> ))} </div> </ScrollArea> <form onSubmit={handleSubmit} className="mt-4 flex gap-2"> <Input value={input} onChange={handleInputChange} placeholder="Type your message..." disabled={isLoading} className="flex-1" /> <Button type="submit" disabled={isLoading}> {isLoading ? 'Sending...' : 'Send'} </Button> </form> </Card> ) } - Step 14
Implement Supabase Authentication
Set up GitHub OAuth authentication with proper session management. This integrates seamlessly with Supabase's built-in auth system.
// app/auth/callback/route.ts import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' export async function GET(request: Request) { const requestUrl = new URL(request.url) const code = requestUrl.searchParams.get('code') const origin = requestUrl.origin if (code) { const supabase = await createClient() await supabase.auth.exchangeCodeForSession(code) } return NextResponse.redirect(`${origin}/`) } // components/auth-button.tsx 'use client' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' export function AuthButton({ user }: { user: any }) { const supabase = createClient() const handleSignIn = async () => { await supabase.auth.signInWithOAuth({ provider: 'github', options: { redirectTo: `${location.origin}/auth/callback`, }, }) } const handleSignOut = async () => { await supabase.auth.signOut() location.reload() } return user ? ( <Button onClick={handleSignOut}>Sign Out</Button> ) : ( <Button onClick={handleSignIn}>Sign In with GitHub</Button> ) } - Step 15
Configure Supabase Auth Provider
Enable GitHub OAuth in your Supabase dashboard. Go to Authentication > Providers > GitHub and add your OAuth credentials.
# 1. Create a GitHub OAuth App at https://github.com/settings/developers # 2. Set Authorization callback URL to: https://your-project.supabase.co/auth/v1/callback # 3. Copy Client ID and Client Secret to Supabase dashboard # 4. Enable GitHub provider in Supabase Auth settings⚠ Heads up: For local development, add http://localhost:54321/auth/v1/callback as an additional callback URL. - Step 16
Create Next.js Middleware for Auth
Implement middleware to refresh Supabase sessions automatically and protect authenticated routes.
// middleware.ts import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function middleware(request: NextRequest) { let response = NextResponse.next({ request: { headers: request.headers, }, }) 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, options }) => { request.cookies.set(name, value) }) response = NextResponse.next({ request, }) cookiesToSet.forEach(({ name, value, options }) => { response.cookies.set(name, value, options) }) }, }, } ) const { data: { user } } = await supabase.auth.getUser() // Redirect to login if accessing protected route without auth if (!user && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)) } return response } export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], } - Step 17
Implement AI Tool Calling
Add function calling capabilities to enable AI to interact with external tools and APIs. This is powerful for building AI agents.
// app/api/chat-with-tools/route.ts import { anthropic } from '@ai-sdk/anthropic' import { streamText, tool } from 'ai' import { z } from 'zod' export const runtime = 'edge' export async function POST(req: Request) { const { messages } = await req.json() const result = streamText({ model: anthropic('claude-3-5-sonnet-20241022'), messages, tools: { weather: tool({ description: 'Get the current weather for a location', parameters: z.object({ location: z.string().describe('The city and state, e.g. San Francisco, CA'), }), execute: async ({ location }) => { // In production, call a real weather API return { location, temperature: 72, condition: 'sunny', } }, }), searchDatabase: tool({ description: 'Search the knowledge base for relevant information', parameters: z.object({ query: z.string().describe('The search query'), }), execute: async ({ query }) => { // Call your RAG endpoint const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/rag/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), }) return await response.json() }, }), }, maxSteps: 5, }) return result.toDataStreamResponse() } - Step 18
Set Up Production Deployment on Vercel
Configure your project for deployment to Vercel with optimized settings for AI workloads.
// vercel.json { "buildCommand": "next build", "devCommand": "next dev --turbopack", "framework": "nextjs", "regions": ["iad1"], "env": { "NEXT_PUBLIC_SUPABASE_URL": "@supabase-url", "NEXT_PUBLIC_SUPABASE_ANON_KEY": "@supabase-anon-key" }, "functions": { "app/api/chat/route.ts": { "maxDuration": 60 }, "app/api/rag/query/route.ts": { "maxDuration": 30 } } }⚠ Heads up: Set environment variables in Vercel dashboard, not in vercel.json for sensitive keys like ANTHROPIC_API_KEY. - Step 19
Configure TypeScript Paths and Compilers
Optimize TypeScript configuration for Next.js 15 and enable strict mode for better type safety.
// tsconfig.json { "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } - Step 20
Best Practices: Error Handling
Implement comprehensive error handling for AI responses and stream interruptions. The Vercel AI SDK provides hooks for error management.
Key practices:
- Always validate inputs with Zod before processing AI requests
- Use try-catch blocks in API routes and wrap async operations
- Implement timeout handlers for long-running AI operations
- Provide fallback responses when AI services are unavailable
- Log errors properly but never expose API keys or sensitive data
- Handle stream interruptions gracefully when users navigate away
Common pitfalls to avoid:
- Not validating user inputs before sending to AI APIs
- Exposing error stack traces to users in production
- Missing rate limiting on AI endpoints (can be expensive)
- Not handling Supabase session expiration
- Forgetting to clean up event listeners in streaming components
- Step 21
Best Practices: Security
Security is critical when building AI applications with user data and API access.
Essential security measures:
- Row Level Security (RLS): Always enable RLS on Supabase tables
- Input sanitization: Validate and sanitize all user inputs before AI processing
- API key protection: Never expose service role keys to the client
- Rate limiting: Implement rate limits on AI endpoints to prevent abuse
- Content filtering: Add content moderation for user-generated prompts
- CORS configuration: Restrict API access to your domains only
Supabase RLS example:
-- Only allow users to read their own data create policy "Users can only view own conversations" on conversations for select using (auth.uid() = user_id); -- Prevent privilege escalation create policy "Users cannot modify other users' data" on conversations for update using (auth.uid() = user_id); - Step 22
Best Practices: Performance Optimization
Optimize your AI application for production performance and cost efficiency.
Performance tips:
- Enable prompt caching: Anthropic's prompt caching can reduce costs by 90%
- Use streaming: Always stream AI responses for better UX
- Implement request deduplication: Prevent duplicate AI calls
- Optimize vector search: Use proper indexing (IVFFlat or HNSW)
- Edge runtime: Use edge functions for low-latency responses
- Database connection pooling: Use Supabase pooler for high traffic
Next.js 15 specific optimizations:
- Use
loading.tsxfiles for instant loading states - Implement React Suspense boundaries around AI components
- Use Server Components for initial data fetching
- Enable Turbopack for faster local development
- Leverage Next.js 15's improved caching with
unstable_cache
- Step 23
Common Pitfalls and Solutions
Learn from common mistakes when building AI-native applications.
Pitfall #1: Not handling streaming errors
Problem: Stream interruptions cause UI to freeze
Solution: Always implement
onErrorhandlers inuseChatPitfall #2: Supabase session expiration
Problem: Users get logged out during long AI conversations
Solution: Implement session refresh in middleware and use
@supabase/ssrPitfall #3: Vector embedding costs
Problem: Expensive embeddings for every query
Solution: Cache embeddings and use hybrid search (keyword + vector)
Pitfall #4: Turbopack compatibility issues
Problem: Some packages don't work with Turbopack
Solution: Use
next devwithout--turbopackif needed, or update dependenciesPitfall #5: Edge runtime limitations
Problem: Node.js APIs not available in edge runtime
Solution: Use Node.js runtime for complex operations, edge for simple streaming
- Step 24
Migration Path from Older Stacks
If you're migrating from an older tech stack, follow this upgrade path:
From Next.js 13/14 to 15:
- Update to React 19:
npm install react@rc react-dom@rc - Update Next.js:
npm install next@latest - Replace
metadataexports with async functions if needed - Update middleware to handle new cookie API
- Test App Router changes (stricter now)
From Tailwind CSS 3 to 4:
- Install v4:
npm install tailwindcss@next - Move
tailwind.config.jscontent to CSS using@theme - Update
@importstatements in global CSS - Remove PostCSS
tailwindcssplugin if using new defaults
From LangChain to Vercel AI SDK:
- Replace LangChain streaming with
streamText() - Convert LangChain tools to Zod-based
tool()definitions - Update prompt templates to message arrays
- Migrate callbacks to
onFinishhooks
- Update to React 19:
- Step 25
Testing AI Features
Set up comprehensive testing for AI-powered features.
Unit testing with Jest:
// __tests__/api/chat.test.ts import { POST } from '@/app/api/chat/route' import { chatRequestSchema } from '@/lib/schemas/chat' describe('/api/chat', () => { it('validates request schema', () => { const validRequest = { messages: [{ role: 'user', content: 'Hello' }], temperature: 0.7, maxTokens: 100, } expect(() => chatRequestSchema.parse(validRequest)).not.toThrow() }) it('rejects invalid messages', () => { const invalidRequest = { messages: [{ role: 'invalid', content: '' }], } expect(() => chatRequestSchema.parse(invalidRequest)).toThrow() }) })Integration testing:
Use Playwright for E2E tests with actual AI responses. Mock AI responses in CI to avoid costs.
- Step 26
Production Checklist
Before deploying to production, verify these items:
Environment & Deployment:
- [ ] All environment variables set in Vercel dashboard
- [ ] Supabase production instance configured
- [ ] Database migrations applied
- [ ] RLS policies tested and enabled
- [ ] API keys rotated and secured
Performance:
- [ ] Prompt caching enabled for Anthropic API calls
- [ ] Vector indexes created on embeddings table
- [ ] Edge functions deployed for low-latency routes
- [ ] Database connection pooling configured
- [ ] Rate limiting implemented on AI endpoints
Security:
- [ ] Content moderation enabled
- [ ] Input validation on all API routes
- [ ] CORS configured properly
- [ ] Service role key never exposed to client
- [ ] Session refresh working correctly
Monitoring:
- [ ] Error tracking configured (Sentry, LogRocket)
- [ ] AI usage monitoring set up
- [ ] Cost alerts configured for API usage
- [ ] Performance monitoring enabled
User Experience:
- [ ] Loading states for all AI interactions
- [ ] Error messages user-friendly
- [ ] Streaming working smoothly
- [ ] Mobile responsive design
- [ ] Accessibility tested
Feature requests
Sign in to suggest features or vote on existing ones.
No feature requests yet.
Discussion
Sign in to join the discussion.
No comments yet.