AffinEQ

Product

Platform overview Integrations Verified, not claimed What isn't built yet

Solutions

Mobile-first startups Agencies building for SMEs Fintechs & regulated teams What people build

Migrate

How a move works From Supabase From Appwrite From Firebase From AWS Amplify From any Postgres Pricing

Developers

Documentation Next.js quickstart JavaScript quickstart Changelog Support Contribute Platform status

Company

Why we exist Customers Blog Partners Contact Sign in Start building
Documentation / Quickstarts

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() or getSession()? getUser() asks AffinEQ. getSession() returns what the cookie says, and a browser can be made to send a cookie. Use getUser() 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.

Type to search. Nothing you type leaves this page.