Most tutorials tell you to reach for NextAuth, clerk, or Supabase Auth and call it a day. those are great tools, but i wanted to actually understand what happens between "user clicks sign Up" and "User has a session", so i built it myself for a real project: Favor Stores, an e-commerce app for a client selling electrical appliances.
Here's the full breakdown, the stack, the file structure, and the two mistakes that cost me the most time.
THE STACK
Next.js(App Router, Server Actions)
POstgresSQL, hosted on Supabase
Sequelize as the ORM
bcryptjs for pasword hashing
jose for signing tokens
NO auth-as-a-service. jusr a Users table, a hashed password, and a signed cookie.
Step1: The database layer
Everything starts with a sequelize model and a migration.
npx sequelize-cli model:generate --name User --attributes email:string,password:string,name:string
That generates a model and a migration, I made sure email was enforced as unique and required at the database level,not just i application code:
email: { type: Sequelize.STRING, allowNull: false, unique: true },
Then the model itself, models/user.js:
'use strict';
const { Model } = require('sequelize');
const bcrypt = require('bcryptjs');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
async validatePassword(plain) {
return bcrypt.compare(plain, this.password);
}
}
User.init(
{
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: { isEmail: true },
},
password: { type: DataTypes.STRING, allowNull: false },
name: { type: DataTypes.STRING, allowNull: false },
},
{
sequelize,
modelName: 'User',
hooks: {
beforeCreate: async (user) => {
user.password = await bcrypt.hash(user.password, 10);
},
},
}
);
return User;
};
The important part is the "beforeCreate" hook. Every time User.create() runs, the plaintext password gets replaced with a bcrypt hash before it's written to Postgres. THe app never stores or even sees a raw password past the moment it's submitted.
Run the migration:
npx sequelize-cli db:migrate
and confirm the Users table shows up in SUpabase's table editor.
Step 2: One connection, twosequelize-Cli
This is the part that tripped me up, sequelize-Cli auto-generates a models/index.js file that builds its own Sequelize connect (from config/config.json) purely so the CLI can run migrations and seeders.
if you then import that same file into your running Next.js app, you end up with two seperate connections to the same database, one for the CLI , one for the app, which is wasteful and confusing to debug.
The fix:Keep a single connection the app actually uses, built from your DATABASE_URL:
// lib/db.ts
import { Sequelize } from "sequelize";
import pg from "pg";
declare global {
var sequelize: Sequelize | undefined;
}
const options = {
dialect: "postgres" as const,
dialectModule: pg,
dialectOptions: {
ssl: { require: true, rejectUnauthorized: false },
},
};
let sequelize: Sequelize;
if (process.env.NODE_ENV === "production") {
sequelize = new Sequelize(process.env.DATABASE_URL as string, options);
} else {
if (!global.sequelize) {
global.sequelize = new Sequelize(process.env.DATABASE_URL as string, options);
}
sequelize = global.sequelize;
}
export default sequelize;
The global.sequelize check matters in development, without it, every hot reload spins up a fresh connection, and you'll quietly leak connections until postgres starts rejecting new ones.
Then register the model definition onto this connection instead of the CLI's"
// lib/models.ts
import { DataTypes } from "sequelize";
import sequelize from "./db";
const defineUser = require("../models/user");
export const User = defineUser(sequelize, DataTypes);
export { sequelize };
now the app has exactly one like connection to POstgres, and models/index.js stays untouched, doing its CLI-only job.
Step 3:Sessions, via a signed cookie
NO session table, no Redis - just a signed JWT in an httlpOnly cookie, using jose:
// lib/auth-session.ts
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);
const COOKIE_NAME = "session";
export async function createSession(userId: number) {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(secret);
(await cookies()).set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
}
export async function getSession() {
const token = (await cookies()).get(COOKIE_NAME)?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: number };
} catch {
return null;
}
}
export async function destroySession() {
(await cookies()).delete(COOKIE_NAME);
}
httpOnly: true, is the detail that actually matters here, it means client-side javascript can't read or tamper with the cookie, which closes off a whole class of XSS-based session theft.
SESSION_SECRET is just a long random string,generated once with this command on powershell:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
it's actually tempting to use Supabase own JWT secret here, but don't. That key is scoped to supabase Auth and carries more privilege than your app's session signing needs.
Step 4:The Server Actions
With the model and session helpers in place, the actual signup/login logic is short:
// app/actions/auth.ts
"use server";
import { redirect } from "next/navigation";
import { UniqueConstraintError } from "sequelize";
import { User } from "@/lib/models";
import { createSession, destroySession } from "@/lib/auth-session";
export async function signupAction(prevState: AuthState, formData: FormData) {
// ...server-side field validation...
try {
const existing = await User.findOne({ where: { email } });
if (existing) {
return { ok: false, errors: { email: "An account with this email already exists." } };
}
const user = await User.create({ name, email, password }); // hashed by the model hook
await createSession(user.id);
} catch (err) {
if (err instanceof UniqueConstraintError) {
return { ok: false, errors: { email: "An account with this email already exists." } };
}
return { ok: false, message: "Something went wrong. Try again." };
}
redirect("/");
}
export async function loginAction(prevState: AuthState, formData: FormData) {
// ...server-side field validation...
const user = await User.findOne({ where: { email } });
if (!user || !(await user.validatePassword(password))) {
return { ok: false, message: "Invalid email or password." };
}
await createSession(user.id);
redirect("/");
}
export async function logoutAction() {
await destroySession();
redirect("/login");
}
One detail worth calling out: redirect() in Next.js works by throwing internally,so it has to sit outside any try/catch, otherwise your catch block silently swallows the redirect.
These plug directly into a form via useActionState, so validation errors and field values round-trip back to the client without any client-side fetch code.
The gotcha that cost the most time
Everything above worked on paper but kept failing to connect, until I found the real issue: Supabase's direct database host (db..supabase.co) failed DNS resolution in my environment. Switching DATABASE_URL to the session pooler connection string (found in Supabase's connection settings, under "Session pooler") fixed it immediately.
If your Sequelize connection to Supabase times out or throws a DNS error, check that before anything else.
Dependencies
npm install sequelize pg pg-hstore bcryptjs jose
npm install -D sequelize-cli
Package Role
sequelize ORM β models, migrations, queries
pg / pg-hstore- Postgres driver Sequelize runs on
bcryptjs- Password hashing/verification
jose- Signs/verifies the session JWT
sequelize-cli- Scaffolds models & migrations,
runs db:migrate
Takeaway
None of this is complicated in isolation β hash a password, sign a cookie, query a table. What makes "just build auth yourself" feel intimidating is that the pieces are scattered across five or six small files, and it's easy to accidentally create two DB connections or leave a password unhashed if you're gluing it together for the first time. Worth doing once, so you know exactly what a library like NextAuth is doing for you under the hood.
Top comments (4)
The unique email constraint at the database is the right call. One step past Sequelize's isEmail shape check is looking up whether the domain publishes MX, because a well-formed address on a dead or Null-MX domain still creates an account nobody can reach.
thats actually a good callout mate, definetly on the list for a next pass
Great write up
Thank you bossππ½ππ½ππ½
Means a lot π₯