Next.js Backend: Postgres, Auth and Storage in One Project
A Next.js backend needs four things: a database, user accounts, file storage and a safe place to run code with secrets. You can get all four from one StitchDB project and talk to it from both client components and server code with the same stitchdb-js client. This guide builds a small App Router app with a todo table, sign-in, avatar uploads and a route handler, and shows which key goes where.
The short version: the browser uses the anon key and Row Level Security decides what each user sees. Server code that needs full access uses the service_role key, and that key never gets a NEXT_PUBLIC_ prefix.
#What the Next.js backend looks like
| Piece | Where it runs | Key | Who decides access |
|---|---|---|---|
Client components ('use client') | Browser | anon | Row Level Security, per user |
| Server components | Your Next.js server | anon (public data) | Row Level Security, as anon |
| Route handlers acting for a user | Your Next.js server | anon + user's token | Row Level Security, as that user |
| Route handlers doing admin work | Your Next.js server | service_role | Your own code |
| Edge Functions | StitchDB, next to the database | built in | Your own code or RLS |
The point of this layout is that most of your data access needs no API code at all. The REST API is generated from your tables (REST API docs), and policies in Postgres do the authorization. You only write route handlers for things the client must not do itself.
#1. Create the project and the table
Create a project, open the SQL editor and run the schema from the quickstart:
create table public.todos (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users on delete cascade,
task text not null,
is_complete boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos" on public.todos
for all to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());Two details matter here. user_id defaults to auth.uid(), so the client never sends a user id it could fake. And Row Level Security is turned on in the same script. Postgres creates tables with RLS off, and tables in public are reachable through the API right away. RLS Guard, on by default for new projects, turns RLS on for new tables, but writing it into the migration keeps it correct on every project. The Row Level Security guide covers more policy patterns.
#2. Environment variables
Put the project URL and keys in .env.local:
NEXT_PUBLIC_STITCHDB_URL=https://<project-ref>.p.stitchdb.com
NEXT_PUBLIC_STITCHDB_ANON_KEY=<anon-key>
STITCHDB_SERVICE_ROLE_KEY=<service-role-key>Anything with NEXT_PUBLIC_ ends up in the browser bundle. That is fine for the anon key: it only proves the request comes from your app. The service_role key bypasses Row Level Security, so it stays unprefixed and is only read in server code.
If you deploy on Vercel, the Vercel integration writes these variables for you. Pick the NEXT_PUBLIC_ prefix and it writes NEXT_PUBLIC_STITCHDB_URL and NEXT_PUBLIC_STITCHDB_ANON_KEY, plus unprefixed copies for server code. It always writes STITCHDB_SERVICE_ROLE_KEY without a prefix, and it can also add DATABASE_URL and DIRECT_URL if you use Prisma or Drizzle. When you rotate keys, linked projects get the new ones on the next deployment.
#3. One shared client
npm install stitchdb-js// lib/stitchdb.ts
import { createClient } from 'stitchdb-js'
export const stitchdb = createClient(
process.env.NEXT_PUBLIC_STITCHDB_URL!,
process.env.NEXT_PUBLIC_STITCHDB_ANON_KEY!,
{ auth: { persistSession: true, autoRefreshToken: true } },
)Create one client and reuse it. In the browser the session is stored in localStorage and the access token is refreshed before it expires. If you want typed queries, generate a Database type with the CLI and pass it as createClient<Database>(...):
stitchdb gen types typescript --linked -o src/database.types.ts#4. Sign-in in a client component
'use client'
import { useState } from 'react'
import { stitchdb } from '@/lib/stitchdb'
export function SignIn() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
const { error } = await stitchdb.auth.signInWithPassword({ email, password })
setError(error ? error.message : null)
}
return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
<button>Sign in</button>
{error && <p>{error}</p>}
</form>
)
}New projects confirm e-mail addresses by default, so signUp returns no session until the user clicks the link. Set emailRedirectTo to a page of your app and add that URL to the project's redirect URLs. The Site URL defaults to http://localhost:3000, which matches next dev; change it to your production URL before launch.
If you would rather not build sign-in screens at all, turn on the hosted sign-in page and send users there. With the PKCE flow the user comes back with a one-time code instead of tokens in the address bar:
import { createClient } from 'stitchdb-js'
const stitchdb = createClient(STITCHDB_URL, STITCHDB_ANON_KEY, { auth: { flowType: 'pkce' } })
// on your sign-in button
await stitchdb.auth.signInWithHostedPage({ redirectTo: 'https://yourapp.com/auth/callback' })On /auth/callback the client exchanges ?code= for a session by itself. The hosted page handles sign-up, magic links and codes, password reset, two-step codes and the OAuth providers you turned on.
#5. Read and write data from the client
With the session in place, queries need no user filter. The policy adds it:
'use client'
import { useEffect, useState } from 'react'
import { stitchdb } from '@/lib/stitchdb'
export function Todos() {
const [todos, setTodos] = useState<{ id: number; task: string; is_complete: boolean }[]>([])
useEffect(() => {
stitchdb
.from('todos')
.select('id, task, is_complete')
.order('created_at', { ascending: false })
.range(0, 9)
.then(({ data }) => setTodos(data ?? []))
}, [])
async function add(task: string) {
const { data, error } = await stitchdb.from('todos').insert({ task }).select()
if (!error && data) setTodos((t) => [...data, ...t])
}
return <ul>{todos.map((t) => <li key={t.id}>{t.task}</li>)}</ul>
}Every call resolves to { data, error } and does not throw unless you add .throwOnError(). A 403 or Postgres code 42501 means a policy or a missing grant blocked the request. The errors reference lists the rest.
#6. Server components for public data
Server components run on your server and have no browser session. Use them for data anyone may read, with the anon key, and let a policy define "anyone":
create policy "Public posts are readable" on public.posts
for select to anon, authenticated using (published);// app/blog/page.tsx
import { createClient } from 'stitchdb-js'
const stitchdb = createClient(process.env.STITCHDB_URL!, process.env.STITCHDB_ANON_KEY!, {
auth: { persistSession: false },
})
export default async function Blog() {
const { data: posts } = await stitchdb.from('posts').select('id, title').order('id', { ascending: false })
return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}persistSession: false keeps the server client from trying to store a session.
#7. Route handlers that act as the user
Some work must happen on the server but still as the signed-in user, so policies keep applying. Send the user's access token from the browser, and forward it on the server. This is the same pattern StitchDB uses in its Edge Function examples:
// app/api/todos/route.ts
import { createClient } from 'stitchdb-js'
export async function GET(request: Request) {
const db = createClient(process.env.STITCHDB_URL!, process.env.STITCHDB_ANON_KEY!, {
global: { headers: { Authorization: request.headers.get('Authorization') ?? '' } },
auth: { persistSession: false },
})
const { data: { user } } = await db.auth.getUser()
if (!user) return Response.json({ error: 'Sign in first' }, { status: 401 })
const { data, error } = await db.from('todos').select('*')
if (error) return Response.json({ error: error.message }, { status: 400 })
return Response.json(data)
}// in a client component
const { data: { session } } = await stitchdb.auth.getSession()
const res = await fetch('/api/todos', {
headers: { Authorization: `Bearer ${session?.access_token}` },
})getUser() asks the Auth server, so a deleted or banned account is caught even if its token has not expired yet.
#8. Route handlers with the service role key
For trusted work, such as counting all orders for an admin page, create a separate client with the service_role key. It bypasses Row Level Security, so check who is calling before you use it:
// app/api/admin/stats/route.ts
import { createClient } from 'stitchdb-js'
const admin = createClient(process.env.STITCHDB_URL!, process.env.STITCHDB_SERVICE_ROLE_KEY!, {
auth: { persistSession: false, autoRefreshToken: false },
})
export async function GET() {
// check the caller is an admin first (for example a role in app_metadata)
const { count } = await admin.from('orders').select('*', { count: 'exact', head: true })
return Response.json({ orders: count })
}#9. File uploads
Create a public avatars bucket in Studio, then allow each user to write to their own folder:
create policy "Users manage files in their folder" on storage.objects
for all to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text)
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);Upload straight from the browser. The file never passes through your Next.js server:
const file = event.target.files[0]
const { data, error } = await stitchdb.storage
.from('avatars')
.upload(`${user.id}/avatar.png`, file, { upsert: true })
const { data: { publicUrl } } = stitchdb.storage.from('avatars').getPublicUrl(data.path)Upserting needs select and update policies too; the for all policy above covers them. To show a 200 pixel thumbnail, ask for a resized URL instead of the original:
const { data: thumb } = stitchdb.storage.from('avatars').getPublicUrl(`${user.id}/avatar.png`, {
transform: { width: 200, height: 200, resize: 'cover', quality: 75 },
})The Free plan includes 1,000 image transformations a month and Pro 25,000 (check pricing for current numbers). The file uploads guide goes deeper on private buckets and signed URLs.
#Route handlers or Edge Functions?
Both run server code. Route handlers live in your Next.js deployment and are the natural place for code that renders or shapes data for your pages. Edge Functions run on the StitchDB server next to your database and get the project URL, keys and a pooled Postgres URL as built-in variables. They suit webhooks (Stripe, GitHub), scheduled jobs called from SQL with pg_cron, and work you want to call from mobile apps too, not only from your Next.js site. The Edge Functions guide has examples.
#Trade-offs to know
- StitchDB projects run in EU data centers (Germany and Finland). If your Next.js app is deployed in a US region, every database call crosses the Atlantic. Put your deployment in an EU region, or rely more on client-side calls, which go straight from the user's browser to the API.
- The JavaScript SDK documents no cookie-based session helper for server-side rendering. The patterns above (client-side session, token forwarded to route handlers, anon key for public server reads) work without one. StitchDB speaks the same Auth API as the open-source Supabase stack, so client libraries written for that stack can be pointed at your project if you prefer a cookie setup.
- Response sizes are capped per plan (1,000 rows per request on Free, 5,000 on Pro). Page with
.range().
#FAQ
#Can I use Prisma or Drizzle in Next.js with StitchDB?
Yes. Use the pooled connection string on port 6543 (transaction mode) as DATABASE_URL, with sslmode=require. ORM connections run as the project owner, so Row Level Security does not protect them; keep them in server code.
#Is it safe to put the anon key in a NEXT_PUBLIC variable?
Yes, as long as every table in public has Row Level Security on with sensible policies. The anon key is meant for browsers. The service_role key is not.
#Do I need an API layer at all?
Often not. Reads and writes from client components go to the generated REST API, and policies do the authorization. Add route handlers or Edge Functions for secrets, third-party APIs and admin tasks.
#How do I get realtime updates in a Next.js page?
Add the table to the stitchdb_realtime publication and subscribe from a client component with stitchdb.channel(...).on('postgres_changes', ...). Each user only receives rows their policies let them select.
Start a free project and follow along: create your account.