SaaS starter
Teams for a Next.js app. lib/vaif.ts is the browser client, with the publishable key. lib/vaif-server.ts has createVaifServer(), a server client with the secret key, and getCurrentUser(), which asks the API who is signed in with the user’s own access token from the vaif-access-token cookie your sign-in page sets. lib/auth.ts builds on them:
getCurrentUser()andrequireUser()for the signed-in user;createTeam(),getUserTeams(),getTeam(),renameTeam(),deleteTeam()andgetTeamMembers()for teams;inviteToTeam(),listTeamInvites(),revokeTeamInvite(),getMyTeamInvites()andacceptTeamInvite()for invites by email;updateMemberRole()andremoveMember()for members;requireTeamRole(), which checks that the user has one of the roles you name in a team:owner,admin,memberorviewer.
Both server files import server-only, so a build that pulls them into browser code fails. Call the helpers from Server Components, Route Handlers and Server Actions.
Create the app
Section titled “Create the app”vaif init --template saas-starterRun it in your app's folder. It writes vaif.config.json and the template's files there. It stops if the folder already has a vaif.config.json, and leaves any other file that already exists alone; --force overwrites both.
It asks which features to include. This template has files for Database and Functions; Database and Functions are selected to start with. To skip the question, name them: vaif init --template saas-starter --features database,functions. Add one later with vaif init --template saas-starter --add-features <feature>.
What it writes
Section titled “What it writes”| File | Written |
|---|---|
lib/vaif.ts | Always |
lib/vaif-server.ts | Always |
lib/auth.ts | Always |
drizzle/0002_teams.sql | Always |
.env.example | Always |
README.md | Always |
drizzle/0001_initial.sql | With Database |
drizzle.config.ts | With Database |
src/db/schema.ts | With Database |
functions/hello.ts | With Functions |
Install
Section titled “Install”This template adds files to an app you already have, and writes no package.json. Install the packages its files import:
npm install @vaifstudio/client @vaifstudio/auth @vaifstudio/react server-onlyConfigure
Section titled “Configure”Copy .env.example to .env and fill it in. The project ID and the keys are on Security > Credentials in the console.
| Variable | Value |
|---|---|
NEXT_PUBLIC_VAIF_API_URL | The API address. Keep the value in the file, https://api.vaif.studio. |
NEXT_PUBLIC_VAIF_PROJECT_ID | Your project ID, for the app. |
NEXT_PUBLIC_VAIF_PUBLISHABLE_KEY | The project's publishable key (vk_pub_). Row rules apply to it, so it is safe in an app. |
VAIF_SECRET_KEY | The project's secret key (vk_srv_), for server code only. It bypasses row rules; never ship it in a browser or mobile app. |
VAIF_PROJECT_ID | Your project ID again, without the prefix, for the server client. |
Set up the database
Section titled “Set up the database”The template writes two SQL files for your database, applied in name order. The comment line above each CREATE TABLE sets that table's row rules.
drizzle/0001_initial.sql, written with the Database feature, is an example: edit it to describe your own tables before you apply it.
-- Initial migration. Customize this schema for your project; vaif db push-- applies it. The comment directly above each CREATE TABLE sets that table's-- row rules: here each signed-in user reads and changes only their own rows,-- and the owner column is filled in from their sign-in on insert. Keep a rule-- comment on every table you add: a table with no row rules is closed to-- signed-out visitors but open to every signed-in user.
-- vaif:owner idCREATE TABLE IF NOT EXISTS "users" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "email" text NOT NULL UNIQUE, "name" text, "created_at" timestamp with time zone NOT NULL DEFAULT now());
-- vaif:owner user_idCREATE TABLE IF NOT EXISTS "posts" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "user_id" uuid NOT NULL REFERENCES "users"("id"), "title" text NOT NULL, "content" text, "published" boolean NOT NULL DEFAULT false, "created_at" timestamp with time zone NOT NULL DEFAULT now());users(-- vaif:owner id): each signed-in user reads and changes only their own rows, andidis filled in from their sign-in.posts(-- vaif:owner user_id): each signed-in user reads and changes only their own rows, anduser_idis filled in from their sign-in.
drizzle/0002_teams.sql, written every time:
-- Teams, memberships and invites, applied by vaif db push after-- 0001_initial.sql. All three are server-only: no user reads or changes them-- directly. Your server (lib/auth.ts, with the secret key) reads, renames and-- deletes a team, and changes its memberships and invites, only after it-- checks the caller's team_members role or verified email, so no user can add-- themselves to a team, and an owner who is removed or demoted loses the team-- at once. An invite expires 7 days after it is sent, and is accepted only-- while its sender may still send it.
-- vaif:server-onlyCREATE TABLE IF NOT EXISTS "teams" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "name" text NOT NULL, "slug" text NOT NULL UNIQUE, -- Who created the team. It grants nothing: the team's owners are its -- team_members rows with role 'owner'. "created_by" uuid NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now());
-- vaif:server-onlyCREATE TABLE IF NOT EXISTS "team_members" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "team_id" uuid NOT NULL REFERENCES "teams"("id") ON DELETE CASCADE, "user_id" uuid NOT NULL, "role" text NOT NULL DEFAULT 'member', "joined_at" timestamp with time zone NOT NULL DEFAULT now(), UNIQUE ("team_id", "user_id"));
-- vaif:server-onlyCREATE TABLE IF NOT EXISTS "team_invites" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "team_id" uuid NOT NULL REFERENCES "teams"("id") ON DELETE CASCADE, "email" text NOT NULL, "role" text NOT NULL DEFAULT 'member', "invited_by" uuid NOT NULL, "created_at" timestamp with time zone NOT NULL DEFAULT now(), -- lib/auth.ts inviteToTeam sets it 7 days after the invite is sent; a row -- written without one is already expired. "expires_at" timestamp with time zone NOT NULL DEFAULT now(), UNIQUE ("team_id", "email"));teams(-- vaif:server-only): only your server, with the secret key, reads or changes it; no user can, signed in or not.team_members(-- vaif:server-only): only your server, with the secret key, reads or changes it; no user can, signed in or not.team_invites(-- vaif:server-only): only your server, with the secret key, reads or changes it; no user can, signed in or not.
Put your project ID in vaif.config.json ("projectId"), sign in, and apply them. With no --dir, vaif db push finds the template's SQL folder, or a ./migrations folder, and applies its .sql files in name order.
vaif loginvaif db push --dry-run # what each file would changevaif db pushNo user reads or changes the team tables directly, so nobody can add themselves to a team. lib/auth.ts checks the caller’s role in that team first, then makes the change with the server client:
- a team’s owners and admins invite, change roles and remove members, and grant only a role at or below their own; a team always keeps an owner;
- a team’s owners are its members with the
ownerrole, so an owner who is removed or demoted loses the team at once; - an invite names an email address and lasts 7 days. Only the person who signs in with that address, once it is verified, can accept it, and only while the person who sent it may still send it.
Next steps
Section titled “Next steps”- Authentication for signing users in and their sessions.
- Key types for what the publishable and secret keys may do.
- Server-only and shared tables for the rule on the team tables.