Skip to content
Orvanov0.2.0

Next.js quickstart

You build a Next.js App Router app where people sign up with an email and a password, sign in, see their name and email, and sign out. It uses @orvano/nextjs 0.2.0: server actions sign people in, and the session lives in two cookies. It takes about ten minutes.

The finished app is examples/nextjs-quickstart in the Orvano repo. Every file below comes from it, and CI runs it on every change.

If Orvano isn’t running on your machine yet, run these three commands. Run Orvano locally explains each one.

Terminal window
mkdir orvano-local && cd orvano-local
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/install" ghcr.io/orvanohq/orvano:0.2.0 install --local --yes
docker compose up -d --wait

You should see every service report Healthy, and the console open at http://localhost:7700.

  1. Open the setup link the installer printed and create the first admin. You land in your personal org.

    The setup page with a name, email, and password filled in, above the Create the first admin button.The setup page with a name, email, and password filled in, above the Create the first admin button.
  2. Select Create project, name it, and select Create project again. Setting it up takes a few seconds.

    The Create a project dialog with the name My app.The Create a project dialog with the name My app.
  3. On the project’s overview, copy the Project ID. Your app sends it with every request.

    The project overview, showing the project ID with a copy button.The project overview, showing the project ID with a copy button.
  4. Open Platforms, select Add platform, keep the type Web, name it, enter localhost as the hostname, and select Add platform. A web platform allows your app’s origin to call Orvano; localhost covers every port, so it covers next dev on 3000.

    The Add a platform dialog with the type Web, the name Local web, and the hostname localhost.The Add a platform dialog with the type Web, the name Local web, and the hostname localhost.

Make a folder for the app with this package.json, then run npm install. It pins @orvano/nextjs to 0.2.0, the version of the Orvano you’re running.

package.json
{
"name": "nextjs-quickstart",
"private": true,
"version": "0.0.0",
"description": "The finished app of the Orvano Next.js quickstart: https://orvano.dev/docs/quickstarts/nextjs/",
"type": "module",
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000"
},
"dependencies": {
"@orvano/nextjs": "0.2.0",
"next": "16.3.6",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"typescript": "~6.0.3"
}
}
Also add tsconfig.json and next.config.ts
tsconfig.json
{
"compilerOptions": {
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"incremental": true,
"esModuleInterop": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {}
export default nextConfig

Create .env.local with your Orvano server and the project ID you copied:

.env.local
NEXT_PUBLIC_ORVANO_ENDPOINT=http://localhost:7700
NEXT_PUBLIC_ORVANO_PROJECT=your-project-id

Then read them in one place:

lib/config.ts
/** Your Orvano server, without `/v1`. */
export const endpoint = process.env.NEXT_PUBLIC_ORVANO_ENDPOINT ?? 'http://localhost:7700'
/** Your project's ID, from the project's overview in the Orvano console. */
export const project = process.env.NEXT_PUBLIC_ORVANO_PROJECT ?? ''
if (project === '') throw new Error('Set NEXT_PUBLIC_ORVANO_PROJECT in .env.local.')
/** This app's own URL. On plain http://localhost the session cookies skip `Secure`. */
export const appUrl = 'http://localhost:3000'

createServerClient makes a client for one request. It reads the user’s session from the request’s cookies and, in server actions, writes a new session to them. currentUser answers null instead of throwing when nobody is signed in.

lib/orvano.ts
import { createServerClient, OrvanoError } from '@orvano/nextjs'
import { cookies, headers } from 'next/headers'
import { appUrl, endpoint, project } from './config'
/** An Orvano client for this request. The user's session lives in its cookies. */
export async function orvano() {
return createServerClient({
endpoint,
project,
appUrl,
cookies: await cookies(),
requestHeaders: await headers(),
})
}
/** The signed in user, or null when nobody is signed in. */
export async function currentUser() {
try {
return await (await orvano()).account.get()
} catch (error) {
if (error instanceof OrvanoError && error.status === 401) return null
throw error
}
}

The proxy runs before every page and refreshes the session when its access token is about to run out, so a signed in user stays signed in:

proxy.ts
import { updateSession } from '@orvano/nextjs/server'
import type { NextRequest } from 'next/server'
import { endpoint, project } from './lib/config'
/** Refreshes the session before its access token runs out, on every page request. */
export function proxy(request: NextRequest) {
return updateSession(request, { endpoint, project })
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Three server actions do the work. account.create and account.createPasswordSession sign the user in, and the client stores the new session in the cookies. When Orvano refuses (a taken email, a wrong password), the action returns its message for the form to show.

app/actions.ts
'use server'
import { OrvanoError } from '@orvano/nextjs'
import { redirect } from 'next/navigation'
import { orvano } from '@/lib/orvano'
/** What a form shows after a failed try. */
export interface FormState {
error: string | null
}
/** Creates the user and signs them in; Orvano sets the session cookies. */
export async function signUp(_state: FormState, form: FormData): Promise<FormState> {
try {
await (
await orvano()
).account.create({
name: field(form, 'name'),
email: field(form, 'email'),
password: field(form, 'password'),
})
} catch (error) {
return failed(error)
}
redirect('/')
}
/** Signs an existing user in with their email and password. */
export async function signIn(_state: FormState, form: FormData): Promise<FormState> {
try {
await (
await orvano()
).account.createPasswordSession({
email: field(form, 'email'),
password: field(form, 'password'),
})
} catch (error) {
return failed(error)
}
redirect('/')
}
/** Ends this session on Orvano and clears the cookies. */
export async function signOut(): Promise<void> {
await (await orvano()).account.deleteCurrentSession()
redirect('/')
}
function field(form: FormData, name: string): string {
const value = form.get(name)
return typeof value === 'string' ? value : ''
}
/** Orvano's message for an expected failure (a taken email, a wrong password); anything else throws. */
function failed(error: unknown): FormState {
if (error instanceof OrvanoError) return { error: error.message }
throw error
}

The forms are a client component, so they can show that message:

app/auth-forms.tsx
'use client'
import { useActionState } from 'react'
import { signIn, signUp, type FormState } from './actions'
const ready: FormState = { error: null }
/** A sign up form and a sign in form, each showing Orvano's message when it fails. */
export function AuthForms() {
const [signUpState, signUpAction, signingUp] = useActionState(signUp, ready)
const [signInState, signInAction, signingIn] = useActionState(signIn, ready)
return (
<>
<h1>Orvano Next.js quickstart</h1>
<form action={signUpAction} aria-labelledby="sign-up">
<h2 id="sign-up">Sign up</h2>
<label>
Name <input name="name" autoComplete="name" />
</label>
<label>
Email <input name="email" type="email" autoComplete="email" required />
</label>
<label>
Password <input name="password" type="password" autoComplete="new-password" required />
</label>
<button type="submit" disabled={signingUp}>
Sign up
</button>
{signUpState.error !== null && <p role="alert">{signUpState.error}</p>}
</form>
<form action={signInAction} aria-labelledby="sign-in">
<h2 id="sign-in">Sign in</h2>
<label>
Email <input name="email" type="email" autoComplete="email" required />
</label>
<label>
Password{' '}
<input name="password" type="password" autoComplete="current-password" required />
</label>
<button type="submit" disabled={signingIn}>
Sign in
</button>
{signInState.error !== null && <p role="alert">{signInState.error}</p>}
</form>
</>
)
}

The page shows the signed in user, or the forms:

app/page.tsx
import { currentUser } from '@/lib/orvano'
import { signOut } from './actions'
import { AuthForms } from './auth-forms'
/** Shows the signed in user, or the sign up and sign in forms. */
export default async function Home() {
const user = await currentUser()
if (user === null) return <AuthForms />
return (
<section aria-labelledby="signed-in">
<h1 id="signed-in">You're signed in</h1>
<p>Name: {user.name ?? 'No name'}</p>
<p>Email: {user.email}</p>
<form action={signOut}>
<button type="submit">Sign out</button>
</form>
</section>
)
}
Also add app/layout.tsx and app/globals.css
app/layout.tsx
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import './globals.css'
export const metadata: Metadata = {
title: 'Orvano Next.js quickstart',
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
)
}
app/globals.css
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
main {
max-width: 28rem;
margin: 3rem auto;
padding: 0 1rem;
}
form {
display: grid;
gap: 0.75rem;
margin-block: 2rem;
}
label {
display: grid;
gap: 0.25rem;
}
input,
button {
font: inherit;
padding: 0.5rem 0.75rem;
}
[role='alert'] {
color: #b42318;
}

Run npm run dev and open http://localhost:3000.

  1. Fill in the Sign up form and select Sign up. You should see “You’re signed in” with your name and email.
  2. Reload the page. You should still be signed in: the session is in the cookies.
  3. Select Sign out. You should see the two forms again.
  4. Fill in the Sign in form with the same email and password. You should see your name and email again.
  5. Try signing in with a wrong password. You should see Orvano’s message under the form.

The new user is in the console too: open your project’s Users page.