Disclosure: This article contains affiliate links. If you sign up through my links, I may earn a small commission at no extra cost to you. I only recommend tools I personally use.
If you are familiar with the MERN stack, Next.js and Supabase provide an alternative architecture with less backend boilerplate. Supabase provides managed PostgreSQL, authentication, APIs, and Row Level Security, while Next.js handles the application and UI.
In this tutorial, we will build a task manager with:
- Next.js App Router
- Supabase Auth
- Supabase Postgres
- Row Level Security
- Server Actions
- Zod runtime validation
- Railway deployment
This tutorial targets the current Next.js App Router and uses the Next.js 16 proxy.ts convention. In older Next.js versions, the equivalent file is middleware.ts.
What We’re Building
Users will be able to:
- Create an account
- Confirm their email address
- Sign in and sign out
- Create tasks
- Mark tasks complete
- Delete tasks
The application will use Supabase’s publishable key in the browser and rely on PostgreSQL Row Level Security to prevent users from accessing one another’s tasks.
This is not an argument that Supabase is universally better than MongoDB and Express. It is one practical architecture for applications where managed authentication, relational data, and database-level authorization are useful.
Prerequisites
You will need:
- Node.js supported by the current Next.js release
- A Supabase account
- A GitHub account
- A Railway account
- Basic knowledge of TypeScript, React, and SQL
Supabase and Railway offer free or trial plans, but pricing, quotas, and eligibility can change. Check their current pricing pages before publishing or deploying a production application.
Step 1: Create the Supabase Project
Visit supabase.com, create an account, and create a new project named task-manager.
Choose a strong database password and wait for the project to finish provisioning.
Create the Tasks Table
Open SQL Editor in the Supabase dashboard and run:
CREATE TABLE public.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tasks_title_length
CHECK (char_length(btrim(title)) BETWEEN 1 AND 500)
);
CREATE INDEX tasks_user_id_created_at_idx
ON public.tasks (user_id, created_at DESC);
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated users can view own tasks"
ON public.tasks FOR SELECT TO authenticated
USING ((select auth.uid()) = user_id);
CREATE POLICY "authenticated users can insert own tasks"
ON public.tasks FOR INSERT TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "authenticated users can update own tasks"
ON public.tasks FOR UPDATE TO authenticated
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
CREATE POLICY "authenticated users can delete own tasks"
ON public.tasks FOR DELETE TO authenticated
USING ((select auth.uid()) = user_id);
user_id is NOT NULL because every task must belong to a user. The foreign key also deletes a user’s tasks automatically when that user is deleted.
The composite index supports the application’s main access pattern: filtering by user_id and ordering by created_at.
The RLS policies are the authoritative authorization boundary. TO authenticated excludes anonymous requests, USING controls which existing rows a user may access, and WITH CHECK controls whether a new or updated row is acceptable.
Get the Supabase Credentials
Go to Project Settings → API and copy:
- The project URL
- The project’s publishable key
Older Supabase projects may label the publishable key as the anon key. The key is designed to be exposed in browser applications. RLS—not the secrecy of the publishable key—protects your data.
Never expose a Supabase secret or service_role key in browser code. Those keys bypass RLS and belong only in carefully controlled trusted server environments.
Step 2: Create the Next.js Application
npx create-next-app@latest task-manager --typescript --tailwind --eslint --app
cd task-manager
npm install @supabase/supabase-js @supabase/ssr zod
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
Do not commit .env.local to Git.
Configure Standalone Output
For a self-hosted Node.js deployment, Railway’s current Next.js guide recommends standalone output.
Update next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig
Update the start script in package.json:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "node .next/standalone/server.js",
"lint": "eslint"
}
}
Railway supplies the production PORT environment variable. The standalone Next.js server uses it automatically.
Step 3: Add Supabase Client Helpers
Supabase’s @supabase/ssr package provides helpers for using Supabase Auth with cookies in server-rendered applications. The recommended pattern uses separate browser and server clients, plus a request-level session refresh mechanism.
lib/database.types.ts
export type Database = {
public: {
Tables: {
tasks: {
Row: {
id: string
user_id: string
title: string
completed: boolean
created_at: string
}
Insert: {
id?: string
user_id: string
title: string
completed?: boolean
created_at?: string
}
Update: {
id?: string
user_id?: string
title?: string
completed?: boolean
created_at?: string
}
Relationships: []
}
}
Views: Record<string, never>
Functions: Record<string, never>
Enums: Record<string, never>
CompositeTypes: Record<string, never>
}
}
For real projects, generate this file from your Supabase schema using the Supabase CLI.
lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/lib/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
)
}
lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/lib/database.types'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// Server Components cannot always write response cookies.
// The Proxy handles session refreshes for those requests.
}
},
},
},
)
}
lib/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value)
})
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) => {
supabaseResponse.cookies.set(name, value, options)
})
},
},
},
)
const pathname = request.nextUrl.pathname
const publicPaths = ['/login', '/signup', '/auth/confirm', '/auth/error']
if (publicPaths.some((path) => pathname.startsWith(path))) {
return supabaseResponse
}
const { data } = await supabase.auth.getClaims()
if (!data?.claims) {
const url = request.nextUrl.clone()
url.pathname = '/login'
url.searchParams.set('next', pathname)
return NextResponse.redirect(url)
}
return supabaseResponse
}
Supabase recommends getClaims() for verifying JWT claims when protecting pages and data. Use getUser() when you specifically need the freshest user record from the Auth server. Do not use getSession() alone as an authorization check in server code.
proxy.ts
Create proxy.ts in the project root, at the same level as app:
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
],
}
In Next.js 14 or 15, use middleware.ts and export a function named middleware instead:
npx @next/codemod@latest middleware-to-proxy .
Step 4: Implement Authentication
app/login/page.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { FormEvent, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
setError('')
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/')
router.refresh()
}
return (
<main className="mx-auto mt-20 max-w-sm rounded-lg border p-6">
<h1 className="mb-6 text-2xl font-bold">Sign in</h1>
<form onSubmit={handleLogin} className="space-y-4">
<input type="email" placeholder="Email" value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded border px-4 py-2" required />
<input type="password" placeholder="Password" value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded border px-4 py-2" required />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={loading}
className="w-full rounded bg-green-600 py-2 text-white disabled:opacity-50">
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="mt-4 text-center text-sm">
No account? <Link href="/signup" className="text-green-600 underline">Create one</Link>
</p>
</main>
)
}
app/signup/page.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { FormEvent, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
const supabase = createClient()
export default function SignupPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [confirmationSent, setConfirmationSent] = useState(false)
async function handleSignup(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
setError('')
const { data, error } = await supabase.auth.signUp({ email, password })
if (error) {
setError(error.message)
setLoading(false)
return
}
setLoading(false)
if (data.session) {
router.push('/')
router.refresh()
return
}
setConfirmationSent(true)
}
if (confirmationSent) {
return (
<main className="mx-auto mt-20 max-w-sm rounded-lg border p-6 text-center">
<h1 className="mb-2 text-xl font-bold">Check your email</h1>
<p className="text-sm text-gray-600">
We sent a confirmation link to <strong>{email}</strong>. Open the link to activate your account, then sign in.
</p>
</main>
)
}
return (
<main className="mx-auto mt-20 max-w-sm rounded-lg border p-6">
<h1 className="mb-6 text-2xl font-bold">Create an account</h1>
<form onSubmit={handleSignup} className="space-y-4">
<input type="email" placeholder="Email" value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded border px-4 py-2" required />
<input type="password" placeholder="Password" value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded border px-4 py-2" minLength={6} required />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={loading}
className="w-full rounded bg-green-600 py-2 text-white disabled:opacity-50">
{loading ? 'Creating account...' : 'Sign up'}
</button>
</form>
<p className="mt-4 text-center text-sm">
Already registered? <Link href="/login" className="text-green-600 underline">Sign in</Link>
</p>
</main>
)
}
When email confirmation is enabled, the user is created but data.session is typically null until the email is verified. If confirmation is disabled, Supabase may return an active session immediately.
Email Confirmation Callback
This tutorial uses a server-side token_hash callback. Create app/auth/confirm/route.ts:
import { type EmailOtpType } from '@supabase/supabase-js'
import { NextResponse, type NextRequest } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const tokenHash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
if (tokenHash && type === 'email') {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash: tokenHash })
if (!error) {
return NextResponse.redirect(new URL('/', request.url))
}
}
return NextResponse.redirect(new URL('/auth/error', request.url))
}
Create app/auth/error/page.tsx:
import Link from 'next/link'
export default function AuthErrorPage() {
return (
<main className="mx-auto mt-20 max-w-sm rounded-lg border p-6 text-center">
<h1 className="mb-2 text-xl font-bold">Confirmation failed</h1>
<p className="mb-4 text-sm text-gray-600">
This confirmation link may have expired or already been used.
</p>
<Link href="/signup" className="text-sm text-green-600 underline">
Return to signup
</Link>
</main>
)
}
Configure the Supabase Email Template
In Supabase, open Authentication → Email Templates → Confirm signup and use:
<h2>Confirm your signup</h2>
<p>Follow this link to confirm your account:</p>
<p>
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
Confirm your email
</a>
</p>
Then open Authentication → URL Configuration and set Site URL to the URL currently serving your app:
http://localhost:3000
during local development, or your Railway domain in production.
Because this example uses {{ .SiteURL }} directly in the email template, change the Site URL when switching environments. If you later support multiple environments, custom destinations, or OAuth, configure the relevant Redirect URLs and use a controlled redirect parameter.
Step 5: Add Server Actions and Data Access
Server Actions are useful for mutations, but their arguments must be validated at runtime. TypeScript types alone do not validate values sent over the network.
lib/auth/require-user.ts
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
export async function requireUser() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getClaims()
if (error || !data?.claims) throw new Error('Unauthorized')
const userId = z.string().uuid().parse(data.claims.sub)
return { supabase, userId }
}
lib/queries/tasks.ts
import { requireUser } from '@/lib/auth/require-user'
export async function getTasks() {
const { supabase } = await requireUser()
const { data, error } = await supabase
.from('tasks')
.select('*')
.order('created_at', { ascending: false })
if (error) throw new Error('Unable to load tasks')
return data
}
RLS automatically limits the returned rows to those allowed by the authenticated user, so the query does not need .eq('user_id', userId) for security.
app/actions/tasks.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireUser } from '@/lib/auth/require-user'
const titleSchema = z.string().trim().min(1).max(500)
const idSchema = z.string().uuid()
function getStringValue(formData: FormData, name: string) {
const value = formData.get(name)
if (typeof value !== 'string') throw new Error(`Missing form field: ${name}`)
return value
}
export async function addTask(formData: FormData) {
const { supabase, userId } = await requireUser()
const title = titleSchema.parse(getStringValue(formData, 'title'))
const { error } = await supabase.from('tasks').insert({ title, user_id: userId })
if (error) throw new Error('Unable to create task')
revalidatePath('/')
}
export async function updateTask(formData: FormData) {
const { supabase } = await requireUser()
const id = idSchema.parse(getStringValue(formData, 'id'))
const completed = getStringValue(formData, 'completed') === 'true'
const { error } = await supabase.from('tasks').update({ completed }).eq('id', id)
if (error) throw new Error('Unable to update task')
revalidatePath('/')
}
export async function deleteTask(formData: FormData) {
const { supabase } = await requireUser()
const id = idSchema.parse(getStringValue(formData, 'id'))
const { error } = await supabase.from('tasks').delete().eq('id', id)
if (error) throw new Error('Unable to delete task')
revalidatePath('/')
}
app/actions/auth.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function logout() {
const supabase = await createClient()
const { error } = await supabase.auth.signOut()
if (error) throw new Error('Unable to sign out')
redirect('/login')
}
Step 6: Build the Main Task Page
Replace app/page.tsx with:
import { redirect } from 'next/navigation'
import { addTask, deleteTask, updateTask } from '@/app/actions/tasks'
import { logout } from '@/app/actions/auth'
import { getTasks } from '@/lib/queries/tasks'
export default async function HomePage() {
let tasks
try {
tasks = await getTasks()
} catch {
redirect('/login')
}
return (
<main className="mx-auto max-w-xl p-8">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-bold">My Tasks</h1>
<form action={logout}>
<button type="submit" className="text-sm text-gray-600 underline">
Sign out
</button>
</form>
</div>
<form action={addTask} className="mb-8 flex gap-2">
<input name="title" placeholder="Add a new task..."
className="flex-1 rounded border px-4 py-2"
required maxLength={500} />
<button type="submit" className="rounded bg-green-600 px-4 py-2 text-white">
Add
</button>
</form>
<ul className="space-y-3">
{tasks.map((task) => (
<li key={task.id} className="flex items-center gap-3 rounded-lg border p-4">
<form action={updateTask}>
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="completed" value={String(!task.completed)} />
<button type="submit"
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
className={`h-5 w-5 rounded border-2 ${task.completed ? 'border-green-500 bg-green-500' : 'border-gray-400'}`} />
</form>
<span className={`flex-1 ${task.completed ? 'text-gray-400 line-through' : ''}`}>
{task.title}
</span>
<form action={deleteTask}>
<input type="hidden" name="id" value={task.id} />
<button type="submit" className="text-sm text-red-500">Delete</button>
</form>
</li>
))}
</ul>
{tasks.length === 0 && (
<p className="mt-8 text-center text-gray-500">No tasks yet. Add one above.</p>
)}
</main>
)
}
For a more polished application, add pending states and user-friendly action errors with React’s useActionState and useFormStatus.
Step 7: Test Locally
npm run dev
Open http://localhost:3000 and test this sequence:
- Visit
/signup. - Create an account.
- Open the confirmation email.
- Confirm the account through
/auth/confirm. - Add, complete, and delete a task.
- Sign out.
- Confirm that visiting
/redirects to/login.
If confirmation emails do not arrive, check the Supabase email settings, Site URL, email template, spam folder, and confirmation link destination.
Step 8: Deploy to Railway
Commit the project to GitHub:
git init
git add .
git commit -m "Initial task manager"
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/task-manager.git
git push -u origin main
Replace the repository URL with your own.
Create the Railway Service
- Sign in at railway.com.
- Create a new project.
- Choose Deploy from GitHub repo.
- Connect GitHub if prompted.
- Select the
task-managerrepository. - Start the deployment.
Railway can detect and build many Next.js applications automatically, but the standalone output and start script used in this tutorial make the deployment behavior explicit. Railway Next.js deployment guide
Add Environment Variables
In Railway, open your service’s Variables settings and add:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here
Public variables are embedded into browser bundles where used, so only put non-secret values behind the NEXT_PUBLIC_ prefix.
Generate a Public Domain
After deployment:
- Open the Railway service.
- Go to Settings → Networking.
- Click Generate Domain.
- Copy the generated domain.
- Set that domain as the Supabase Site URL.
A deployed Railway service is not necessarily publicly accessible until you generate a domain. Deployment time also varies.
Pricing Note
Railway’s current documentation describes a one-time $5 trial credit valid for up to 30 days, followed by a Free plan with $1 of monthly resource credit. Trial access and network capabilities can depend on account verification. Verify the current Railway pricing documentation before publishing specific pricing claims.
Stack Summary
| Layer | Technology | Purpose |
|---|---|---|
| UI and application | Next.js App Router | Routing, rendering, and server-side application logic |
| Authentication | Supabase Auth | Signup, email confirmation, login, and sessions |
| Database | Supabase Postgres | Relational task storage |
| API access | Supabase APIs through supabase-js
|
Database queries and mutations |
| Authorization | PostgreSQL RLS | Per-user row isolation |
| Validation | Zod and database constraints | Runtime and storage-level integrity |
| Deployment | Railway | Node.js hosting and GitHub deployments |
Why You Might Prefer This Stack Over Traditional MERN
| Concern | Traditional MERN | Next.js + Supabase |
|---|---|---|
| Database | MongoDB | Managed PostgreSQL |
| Authentication | Custom code or separate provider | Supabase Auth |
| API layer | Express routes | Supabase APIs through supabase-js
|
| Authorization | Application middleware | PostgreSQL RLS |
| Backend boilerplate | Usually more application code | Less code for common CRUD workflows |
| Deployment | Often separate frontend and backend services | One Next.js service can handle both |
This stack reduces backend setup for many applications, but it does not eliminate the need to understand authentication, authorization, validation, database design, monitoring, and deployment.
What to Add Next
- Password reset
- Google or GitHub OAuth
- Better action error messages
- Optimistic UI updates
- Pagination and search
- Supabase Realtime
- Automated tests
- A custom Railway domain
- Database migrations
- GitHub Actions
- Rate limiting and centralized logging
For larger applications, keep the same security principles:
- Never expose secret or service-role keys.
- Validate all Server Action inputs at runtime.
- Treat RLS as the database authorization boundary.
- Do not trust client-provided ownership fields.
- Verify authentication on every protected server operation.
- Keep production and preview redirect URLs explicitly configured.
Resources
- Next.js App Router documentation
- Next.js Proxy documentation
- Supabase SSR documentation
- Supabase Next.js quickstart
- Supabase Row Level Security documentation
- Railway Next.js deployment guide
- Railway free-trial documentation
Found this helpful? Share what you build, and verify service pricing and documentation before publishing any time-sensitive claims.
Top comments (0)