Next.js quickstart
A user signs in by phone, and a Server Component reads their rows, with no flash of a signed-out page. About fifteen minutes, in the App Router.
1. A project and a table
Create a project in the dashboard and copy its publishable key (it starts aq_pub_) from Settings → API keys. In the SQL editor, make a table that belongs to whoever is signed in:
create table todos ( id bigint generated always as identity primary key, user_id uuid not null default auth.uid(), title text not null, done boolean not null default false ); alter table todos enable row level security; create policy "own rows" on todos for all to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); grant select, insert, update, delete on todos to authenticated;
Then under Authentication → Providers → Phone, add a test number with a fixed code, such as +254700000001 and 123456. It behaves like a real one and sends nothing, so you can build without an SMS account.
2. Install
npm install @affineq/js @affineq/ssr
# .env.local NEXT_PUBLIC_AFFINEQ_KEY=aq_pub_...
3. The two clients
The browser keeps a session in cookies, not localStorage, so the same sign-in is visible to a Server Component. One client for components that run in the page:
// lib/affineq.ts
"use client";
import { createBrowserClient } from "@affineq/ssr";
export const affineq = createBrowserClient(process.env.NEXT_PUBLIC_AFFINEQ_KEY!);
And one for the server, built per request, because it holds that request’s user:
// lib/affineq-server.ts
import { cookies } from "next/headers";
import { createServerClient } from "@affineq/ssr";
export async function serverClient() {
const store = await cookies();
return createServerClient(process.env.NEXT_PUBLIC_AFFINEQ_KEY!, {
cookies: {
get: (name) => store.get(name),
set: (name, value, options) => store.set(name, value, options),
remove: (name, options) => store.set(name, "", { ...options, maxAge: 0 }),
},
});
}
4. The proxy
A session is renewed in exactly one place, and it is this one. Do not skip it. It is proxy.ts on Next 16 and middleware.ts before that.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient } from "@affineq/ssr";
export async function proxy(request: NextRequest) {
const written: [string, string, any][] = [];
const affineq = createServerClient(process.env.NEXT_PUBLIC_AFFINEQ_KEY!, {
refresh: true,
cookies: {
get: (name) => request.cookies.get(name),
set: (name, value, options) => { request.cookies.set(name, value); written.push([name, value, options]); },
remove: (name, options) => { request.cookies.delete(name); written.push([name, "", { ...options, maxAge: 0 }]); },
},
});
await affineq.auth.getSession(); // renews it when it is close to expiry
const response = NextResponse.next({ request });
for (const [name, value, options] of written) response.cookies.set(name, value, options);
return response;
}
5. Sign in
A client component asks for a code, then trades it for a session:
// app/signin/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { affineq } from "@/lib/affineq";
export default function SignIn() {
const router = useRouter();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
async function send() {
const { error } = await affineq.auth.signInWithOtp({ phone });
if (error) setError(error.message); else setSent(true);
}
async function verify() {
const { error } = await affineq.auth.verifyOtp({ phone, token: code, type: "sms" });
if (error) setError(error.message); else router.push("/todos");
}
return (
<form onSubmit={(e) => { e.preventDefault(); sent ? verify() : send(); }}>
<input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+254700000001" />
{sent && <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />}
<button type="submit">{sent ? "Sign in" : "Send code"}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
Every call resolves to { data, error } and never throws; an error carries a message and a resolution saying what to do next.
6. Read their data
This is a Server Component. There is no "use client" anywhere in the tree, and the user is already known when it renders:
// app/todos/page.tsx
import { redirect } from "next/navigation";
import { serverClient } from "@/lib/affineq-server";
export default async function Todos() {
const affineq = await serverClient();
const { data } = await affineq.auth.getUser();
if (!data) redirect("/signin");
const { data: todos } = await affineq.from("todos").select("id, title, done").order("id");
return (
<ul>
{todos?.map((t) => <li key={t.id}>{t.done ? "✓ " : ""}{t.title}</li>)}
</ul>
);
}
The query carries the user’s token, so the policy from step 1 decides which rows come back: only theirs.
7. Real SMS codes
When you are ready for real users, add your own SMS provider under Authentication → Providers → Phone: choose Mobitech (Kenya), Africa’s Talking or Twilio, paste its API key and sender, and use Send test message to check the key. We send the codes and handle the rest. You pay the provider directly, so this is open on every plan. Nothing in your app changes.
Two things to get right
getUser()orgetSession()?getUser()asks AffinEQ.getSession()returns what the cookie says, and a browser can be made to send a cookie. UsegetUser()for anything that decides who someone is.- Why the server client does not refresh. Refresh tokens rotate, and presenting an old one revokes the whole session. A Server Component cannot set cookies, so a refresh there would be thrown away and the user signed out for a reason nobody could see. The proxy can set cookies, so it does the renewing.
The whole of this, running, is examples/duka in the repository. For everything else the client does, see the reference.