By the end of this tutorial, you'll have:
Estimated time: 1-2 hours
Before starting, make sure you have:
Let's start by creating a new Next.js 14 project with all the right configurations.
Open your terminal and run:
npx create-next-app@latest textbook-qa --typescript --tailwind --app --no-src-dir --import-alias "@/*"
--typescript → Use TypeScript (type safety!)--tailwind → Install TailwindCSS (rapid styling)--app → Use App Router (modern Next.js)--no-src-dir → Keep files in root (simpler structure)--import-alias "@/*" → Use @/ for importsWhen prompted, select:
app/ directorycd textbook-qa
npm install @supabase/supabase-js @supabase/ssr
@supabase/supabase-js → Supabase client library@supabase/ssr → Server-side rendering support for Supabasetextbook-qaWait 2-3 minutes for the project to provision.
https://)In your project root, create a .env.local file:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=your_project_url_here
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key_here
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
NEXT_PUBLIC_ are exposed to the browser.env.local to Git!In your Supabase dashboard:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create profiles table
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT UNIQUE NOT NULL,
full_name TEXT,
subscription_tier TEXT DEFAULT 'free' CHECK (subscription_tier IN ('free', 'pro', 'unlimited')),
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT,
subscription_status TEXT CHECK (subscription_status IN ('active', 'canceled', 'past_due', 'trialing')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY "Users can view own profile"
ON public.profiles
FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles
FOR UPDATE
USING (auth.uid() = id);
-- Create function to automatically create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
profiles table linked to Supabase Auth usersCreate 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!
)
}
For client-side (browser) usage.
Create lib/supabase/server.ts:
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export function createClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options })
} catch (error) {
// Server Component can't set cookies
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// Server Component can't remove cookies
}
},
},
}
)
}
export function createAdminClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {},
}
)
}
For server-side (API routes, Server Components) usage.
Create app/signup/page.tsx:
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
export default function SignUpPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const router = useRouter()
const supabase = createClient()
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullName,
},
},
})
if (error) {
setError(error.message)
setLoading(false)
return
}
// Redirect to dashboard
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link href="/signin" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSignUp}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input
id="full-name"
name="fullName"
type="text"
required
value={fullName}
onChange={(e) => setFullName(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Full Name"
/>
</div>
<div>
<input
id="email-address"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password (min 6 characters)"
minLength={6}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Sign up'}
</button>
</div>
</form>
</div>
</div>
)
}
Create app/signin/page.tsx with similar structure (see the markdown version for complete code).
Create middleware.ts in the root:
import { createServerClient, type CookieOptions } 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: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response = NextResponse.next({
request: { headers: request.headers },
})
response.cookies.set({ name, value: '', ...options })
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
// Redirect to sign-in if not authenticated
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/signin', request.url))
}
// Redirect to dashboard if authenticated
if (user && (request.nextUrl.pathname.startsWith('/signin') ||
request.nextUrl.pathname.startsWith('/signup'))) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/signin', '/signup'],
}
Create app/dashboard/page.tsx:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
export default async function DashboardPage() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/signin')
}
// Get user profile
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Textbook Q&A</h1>
<form action="/api/auth/signout" method="post">
<button type="submit" className="text-sm text-gray-600 hover:text-gray-900">
Sign Out
</button>
</form>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="bg-white rounded-lg shadow px-6 py-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Welcome back, {profile?.full_name || user.email}!
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
<!-- Dashboard cards -->
</div>
</div>
</main>
</div>
)
}
npm run dev
Open http://localhost:3000 in your browser.
Let's review what we built:
Solution: Double-check your .env.local file. Make sure the keys are copied correctly (no extra spaces), the file is named .env.local, and you restarted the dev server.
Solution: Check your Supabase email settings. For development, disable email confirmation in Settings → Auth → Email.
Before moving to Part 3, try these enhancements: