Next.js App Router
Integrate Next.js route handlers, Auth.js, PostgreSQL, headless React hooks, ciphertext-only records, CSP, and deployment.
Ownfold integrates with the Next.js App Router through standard Request/Response route
handlers. The coordination API runs on the server; encryption, decryption, Recovery Kit operations,
and application-owned lifecycle controls run in Client Components.
The PostgreSQL and Auth.js stack below is one runnable recipe, not a requirement. Use the database and identity choosers to substitute SQLite, Drizzle, Prisma, Better Auth, custom auth, or private single-user mode without changing the Next.js handler.
The route adapter itself is server-only. The browser and React packages shown later are optional and are needed only for browser E2EE in the same Next.js application.
The runnable reference is examples/next-auth-postgres. It combines Next.js, Auth.js email/password
and optional GitHub authentication, node-postgres, a ciphertext-only messages route, and a custom
encrypted chat interface composed from headless hooks.
What you will create
Install packages
pnpm add @ownfold/auth-js@beta @ownfold/next@beta @ownfold/postgres@beta @ownfold/server@beta
Install your existing authentication and PostgreSQL dependencies separately. Ownfold does not create users, sessions, or a database connection.
For the browser chat flow in this guide, also install:
pnpm add @ownfold/browser@beta @ownfold/fetch@beta @ownfold/react@beta
1. Create the database adapter
The direct PostgreSQL adapter accepts a driver implementing the small query contract. With pg:
import { Pool } from "pg"
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
ssl:
process.env.NODE_ENV === "production"
? { rejectUnauthorized: true }
: undefined,
})
Apply the Ownfold PostgreSQL migration and your application-record migration before serving traffic. Vault coordination tables and application ciphertext tables are intentionally separate.
If the host application owns password credentials, keep its account table separate as well. Store only versioned salted password hashes, enforce registration and sign-in rate limits, and return an immutable account ID from the Auth.js Credentials provider. The account password must never become a Recovery Kit secret or vault-key input.
CREATE TABLE IF NOT EXISTS ownfold_example_messages (
user_id text NOT NULL,
id text NOT NULL,
encrypted_payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, id)
);
The user_id and record ID are authorization/context metadata. Message bodies stay inside
encrypted_payload.
2. Configure the vault server
import { authJsUserResolver } from "@ownfold/auth-js"
import { nodePostgresVaultAdapter } from "@ownfold/postgres/node-postgres"
import { createVaultServer } from "@ownfold/server"
import { auth } from "@/auth"
import { pool } from "@/src/lib/database"
export const vaultServer = createVaultServer({
adapter: nodePostgresVaultAdapter(pool),
getUserId: authJsUserResolver(async () => auth()),
})
If your Auth.js setup exposes a different server API, pass a loader that returns its session-shaped
value. The resolver extracts only a non-empty session.user.id; it does not share the session,
cookies, password, or provider tokens with Ownfold.
3. Mount the coordination route
Create a catch-all App Router route:
import { createNextVaultHandlers } from "@ownfold/next"
import { vaultServer } from "@/src/lib/vault-server"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
const handlers = createNextVaultHandlers({
server: vaultServer,
isOriginAllowed: ({ request }) => {
const origin = request.headers.get("origin")
return origin === null || origin === process.env.APP_ORIGIN
},
})
export const GET = handlers.GET
export const POST = handlers.POST
createNextVaultHandlers() delegates both methods to the standard fetch handler. Unknown Ownfold
paths return 404; unsupported methods return 405; request bodies are bounded and schema-validated.
The active host session selects the user—catch-all parameters never do.
Use runtime = "nodejs" with pg, Prisma, Drizzle’s Node drivers, or Node-only auth libraries.
Do not deploy those combinations to Edge. Explicit force-dynamic documents that authenticated
vault metadata must not be statically cached, even though current Next.js route handlers are
dynamic by default.
Origin validation
Same-origin requests often omit Origin; modifying cross-origin requests include it. A production
allowlist should accept the known application origin and reject arbitrary values. Never derive the
allowed origin directly from untrusted Host or forwarded headers unless a narrowly configured
trusted proxy has normalized them.
For a separate frontend domain, configure all of these together:
- explicit CORS origin, methods, and headers;
- credentialed cookies with appropriate
SameSiteandSecureattributes; - Ownfold transport
credentials: "include"; - CSRF/trusted-origin validation at the application boundary.
4. Create the browser client
"use client"
import { createVaultClient } from "@ownfold/browser"
import { createFetchVaultTransport } from "@ownfold/fetch"
export const vault = createVaultClient({
transport: createFetchVaultTransport({
baseURL: "/api/ownfold",
}),
autoLockMs: 10 * 60 * 1_000,
})
The "use client" boundary is mandatory. Do not import this module from:
- a Server Component;
- a route handler;
- a Server Action;
generateMetadata;- static generation or revalidation code;
- a Node background worker.
Importing types from @ownfold/core in server code is safe. Importing a live VaultClient is not.
5. Add the React provider
"use client"
import { VaultProvider } from "@ownfold/react"
import type { ReactNode } from "react"
import { vault } from "@/src/lib/vault-client"
export function Providers({ children }: { readonly children: ReactNode }) {
return <VaultProvider client={vault}>{children}</VaultProvider>
}
Mount the provider in the root layout and import only application-owned styles:
import "./styles.css"
import type { ReactNode } from "react"
import { Providers } from "./providers"
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
The provider initializes after hydration. Server-rendered HTML cannot inspect IndexedDB or unlock the vault. Render loading/locked states instead of assuming the server knows device availability.
6. Compose application-owned controls
"use client"
import { useVault, useVaultLock, useVaultStatus } from "@ownfold/react"
export function VaultControls() {
const vault = useVault()
const state = useVaultStatus()
const { lock, unlock } = useVaultLock()
return (
<section aria-labelledby="key-heading">
<h2 id="key-heading">Chat encryption</h2>
<p>Status: {state.status}</p>
{state.status === "not-created" && (
<button type="button" onClick={() => void vault.createVault()}>Set up</button>
)}
{state.status === "ready-unlocked" && (
<button type="button" onClick={() => lock()}>Lock</button>
)}
{state.status === "ready-locked" && (
<button type="button" onClick={() => void unlock()}>Unlock</button>
)}
</section>
)
}
The application chooses its labels, descriptions, visible actions, confirmation steps, and route.
Use useRecoveryKit, useVaultDevices, useVaultPairing, and useKeyRotation only when those
capabilities belong in the product. Ownfold renders nothing and ships no stylesheet.
Keep third-party scripts off this route where practical. A compromised script executing while the vault is unlocked can read plaintext from the page; E2EE does not protect against malicious runtime JavaScript.
7. Build ciphertext-only record routes
Ownfold coordination routes do not store notes or documents. Create application routes that authenticate normally, derive the canonical record context, validate the envelope, and persist only ciphertext.
import { vaultServer } from "@/src/lib/vault-server"
import { pool } from "@/src/lib/database"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export async function PUT(
request: Request,
context: RouteContext<"/api/notes/[id]">,
) {
const { id } = await context.params
const body: unknown = await request.json()
const validated = await vaultServer.validateEncryptedRecordWrite({
request,
body,
namespace: "notes",
recordId: id,
})
if (validated.status === "error") {
return Response.json(
{ code: validated.error.code, message: validated.error.message },
{ status: mapOwnfoldStatus(validated.error) },
)
}
const userId = await requireUserId(request)
await pool.query(
`INSERT INTO ownfold_example_notes (user_id, id, encrypted_payload)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, id)
DO UPDATE SET encrypted_payload = EXCLUDED.encrypted_payload,
updated_at = now()`,
[userId, id, validated.value.encryptedPayload],
)
return new Response(null, { status: 204 })
}
The browser must encrypt with namespace: "notes", the same immutable id, and the same stable
authenticated user ID as ownerId. A GET route returns the stored envelope unchanged. It must not
decrypt, index, preview, summarize, or log note content.
8. Encrypt and decrypt in a Client Component
"use client"
import { useVault } from "@ownfold/react"
export function NoteEditor({ userId, noteId }: NoteEditorProps) {
const vault = useVault()
async function save(value: { readonly title: string; readonly body: string }) {
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: noteId,
ownerId: userId,
value,
})
if (encrypted.status === "error") return showError(encrypted.error)
await fetch(`/api/notes/${encodeURIComponent(noteId)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ encryptedPayload: encrypted.value }),
})
}
// Load the envelope, then call vault.decryptJson with the same context.
}
Do not pass plaintext to a Server Action. A Server Action executes on the application server, so encryption performed inside it is no longer end-to-end browser encryption.
Response caching and headers
Coordination and private-record responses should use Cache-Control: no-store. Apply a strict CSP
at the Next.js boundary, for example through next.config.ts headers or a trusted reverse proxy.
Start from:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Adapt nonces/hashes to your Next.js deployment. Avoid broad unsafe-inline, third-party tag
managers, plaintext analytics, and error-reporting request-body capture on vault pages.
Server Components and authentication
Server Components may validate the host session to redirect unauthorized users before rendering a page. That check controls page access only; it does not unlock or decrypt the vault. Pass the stable user ID to a Client Component when it is required as authenticated record context, and do not pass session tokens or provider credentials.
Pages Router
@ownfold/next targets Web-standard App Router handlers. For a legacy Pages Router API route, use
createNodeVaultHandler or the host’s Request/Response bridge and disable automatic body parsing
so the Ownfold handler retains bounded request processing. Prefer migrating the coordination route
to the App Router rather than maintaining two protocol surfaces.
Deployment checklist
- Run PostgreSQL migrations before the new application version accepts traffic.
- Use the Node runtime for Node database/auth dependencies.
- Set
APP_ORIGINto a canonical HTTPS origin; validate proxy headers explicitly. - Disable caches for coordination and encrypted-record routes.
- Ensure logs and error tools exclude request/response bodies.
- Confirm the browser bundle does not import server, database, or auth packages.
- Confirm the server bundle does not import
@ownfold/browseror@ownfold/react. - Test onboarding, downloaded-kit verification, restore after clearing site data, revocation, and interrupted rotation against the deployed origin.
Troubleshooting
ReferenceError: indexedDB is not defined
A browser module crossed into the server graph. Add "use client" to the owning module and
ensure no Server Component imports the vault-client value, even indirectly.
The Ownfold route always returns 401
Confirm the Auth.js cookie reaches /api/ownfold/* and the session loader receives the route’s
real Request.headers. Do not add userId to the transport body.
POST returns ORIGIN_NOT_ALLOWED
Compare the browser’s exact Origin with the canonical allowlist, including scheme and port.
Review trusted proxy configuration before using forwarded headers.
A note saves but will not decrypt
Verify the PUT route ID, encryption record ID, namespace, and owner ID are identical. Do not regenerate IDs or use a mutable slug between encryption and retrieval.
Vault state remains on a pending state
Inspect the coordination GET response, IndexedDB availability, and provider placement. The provider must be a mounted Client Component and the catch-all route must include GET and POST.
Complete example
Read examples/next-auth-postgres alongside this guide. It is build-tested and demonstrates
email/password registration, optional GitHub OAuth, the actual package boundaries, schema, route
handlers, custom encryption settings, and a client-side encrypted chat lifecycle.