Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

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 SameSite and Secure attributes;
  • 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_ORIGIN to 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/browser or @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.

Last updated on August 4, 2026

Was this page helpful?