secure-docs

Next.js integration

This guide follows the integration Traduction Expert runs in production: clients upload their documents from the website, the team and the translators read them in a viewer built into the application, and only the client downloads the delivered translation. The code targets Next.js 14 with the App Router and TypeScript; it adapts easily to any Node.js server.

The principle to keep in mind: the browser never talks to secure-docs. Every request comes from your server, which holds the key and decides who is allowed to see what.

1. Environment variables

.env.local
# Base for the API's /v1/... paths (no /v1 at the end).
# Application hosted elsewhere: go through the public URL.
SECURE_DOCS_URL=https://docs.traductionexpert.com/api
# Application on the same server as secure-docs: call the API locally.
# SECURE_DOCS_URL=http://127.0.0.1:4100

# Key created in Dashboard > API keys. Never use the NEXT_PUBLIC_ prefix.
SECURE_DOCS_API_KEY=sd_live_...

# Public URL of your application, used to build your own links.
APP_URL=https://www.example.com

SECURE_DOCS_URL is the base to which the client appends the /v1/… paths. So it does not end with /v1:

  • Application hosted elsewhere: https://docs.traductionexpert.com/api, which gives https://docs.traductionexpert.com/api/v1/files, the public API URL.
  • Application running on the same server as secure-docs: http://127.0.0.1:4100. Requests stay on the loopback interface, without going over the internet or through the public proxy.

SECURE_DOCS_API_KEY is a key created in Dashboard > API keys (see Authentication). Never give it the NEXT_PUBLIC_ prefix, which would expose it in the JavaScript sent to the browser.

2. The server client

A single module talks to the API. It is marked server-only (install the package of the same name) so that an accidental import from a client component fails the build. Each function returns null on failure and logs the error without ever writing the token.

lib/secure-docs.ts
import "server-only";

/**
 * secure-docs client. Imported only by server code: the "server-only"
 * package fails the build if a client component imports it.
 */

const BASE = process.env.SECURE_DOCS_URL;
const KEY = process.env.SECURE_DOCS_API_KEY;

export type DocumentKind = "source" | "delivery";

export interface FileMeta {
  id: string;
  name: string;
  kind: DocumentKind;
  contentType: string;
  size: number;
  pageCount: number;
  createdAt: string;
}

export interface ViewLink {
  token: string;
  /** Viewer hosted by secure-docs. */
  url: string;
  expiresAt: string;
}

export interface ViewInfo {
  fileName: string;
  pageCount: number;
  viewerName: string;
  viewerEmail: string;
  expiresAt: string;
  brand: string;
}

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const TOKEN = /^[A-Za-z0-9_-]{43}$/;

export const isDocumentId = (id: string) => UUID.test(id);
export const isViewToken = (token: string) => TOKEN.test(token);

/** The token is a secret: it must never appear in logs. */
const redact = (path: string) => path.replace(/\/views\/[^/]+/, "/views/[token]");

async function call(path: string, init: RequestInit = {}): Promise<Response | null> {
  if (!BASE || !KEY) throw new Error("SECURE_DOCS_URL and SECURE_DOCS_API_KEY are required");
  try {
    return await fetch(`${BASE}${path}`, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${KEY}` },
      cache: "no-store",
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    console.error(`[secure-docs] ${init.method ?? "GET"} ${redact(path)} failed`, err);
    return null;
  }
}

async function fail(res: Response | null, what: string): Promise<null> {
  if (res) console.error(`[secure-docs] ${what} rejected`, res.status, await res.text().catch(() => ""));
  return null;
}

/** Encrypts and stores a file. Returns its metadata, or null on failure. */
export async function uploadDocument(
  content: Buffer,
  name: string,
  contentType: string,
  kind: DocumentKind = "source",
): Promise<FileMeta | null> {
  const res = await call("/v1/files", {
    method: "POST",
    headers: {
      "Content-Type": contentType,
      "x-file-kind": kind,
      "x-file-name": encodeURIComponent(name),
    },
    body: new Uint8Array(content),
  });
  if (!res || res.status !== 201) return fail(res, "upload");
  return (await res.json()) as FileMeta;
}

/** View link in a viewer's name, burned into the watermark. */
export async function createViewLink(
  fileId: string,
  viewer: { name: string; email: string },
  ttlSeconds: number,
): Promise<ViewLink | null> {
  if (!isDocumentId(fileId)) return null;
  const res = await call("/v1/views", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fileId, viewerName: viewer.name, viewerEmail: viewer.email, ttlSeconds }),
  });
  if (!res || res.status !== 201) return fail(res, "link creation");
  return (await res.json()) as ViewLink;
}

/** Delivered document (kind "delivery"). The API rejects source documents (403). */
export async function downloadDelivery(
  id: string,
  customerEmail?: string,
): Promise<{ content: Buffer; contentType: string; name: string } | null> {
  if (!isDocumentId(id)) return null;
  const res = await call(`/v1/files/${id}/download`, {
    headers: customerEmail ? { "x-viewer-email": customerEmail } : {},
  });
  if (!res?.ok) return fail(res, "download");
  return {
    content: Buffer.from(await res.arrayBuffer()),
    contentType: res.headers.get("content-type") ?? "application/octet-stream",
    name: decodeURIComponent(res.headers.get("x-file-name") ?? "document"),
  };
}

/** A link's metadata. null if the token is invalid, expired or revoked. */
export async function getView(token: string, ip?: string): Promise<ViewInfo | null> {
  if (!isViewToken(token)) return null;
  const res = await call(`/v1/views/${token}`, { headers: ip ? { "x-viewer-ip": ip } : {} });
  return res?.ok ? ((await res.json()) as ViewInfo) : null;
}

/** One watermarked page (image/jpeg), as rendered by the API. */
export async function getViewPage(token: string, n: number, ip?: string): Promise<Response | null> {
  if (!isViewToken(token) || !Number.isInteger(n) || n < 1) return null;
  const res = await call(`/v1/views/${token}/pages/${n}`, { headers: ip ? { "x-viewer-ip": ip } : {} });
  return res?.ok ? res : null;
}

/**
 * End viewer's IP, behind Cloudflare or a reverse proxy.
 * Only read these headers if your proxy overwrites them: otherwise the
 * client can forge them.
 */
export function clientIp(headers: Headers): string | undefined {
  return (
    headers.get("cf-connecting-ip") ??
    headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    headers.get("x-real-ip") ??
    undefined
  );
}

3. Receive files from clients

The route receives a FormData from your form, forwards each file to secure-docs and stores the returned ID in your database. Your application keeps no copy of the file: not on disk, not in a bucket.

app/api/upload/route.ts
import { NextResponse } from "next/server";

import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { uploadDocument } from "@/lib/secure-docs";

export const runtime = "nodejs";

const ACCEPTED = new Set(["application/pdf", "image/jpeg", "image/png"]);
const MAX_BYTES = 25 * 1024 * 1024;

/**
 * Receives a client's files and uploads them to secure-docs.
 * Your database stores only the returned IDs, never the file.
 */
export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Sign-in required." }, { status: 401 });

  const form = await request.formData().catch(() => null);
  const files = form?.getAll("files").filter((f): f is File => f instanceof File) ?? [];
  if (files.length === 0) return NextResponse.json({ error: "No files received." }, { status: 400 });

  const saved: { id: string; name: string; pageCount: number }[] = [];

  for (const file of files) {
    // First check on the application side; the API checks the type again from the bytes.
    if (!ACCEPTED.has(file.type)) {
      return NextResponse.json({ error: `Unsupported format for "${file.name}".` }, { status: 415 });
    }
    if (file.size > MAX_BYTES) {
      return NextResponse.json({ error: `"${file.name}" exceeds 25 MB.` }, { status: 413 });
    }

    const meta = await uploadDocument(Buffer.from(await file.arrayBuffer()), file.name, file.type, "source");
    if (!meta) return NextResponse.json({ error: "Upload failed. Please try again." }, { status: 502 });

    await db.document.create({
      data: { ownerId: user.id, vaultId: meta.id, name: meta.name, pageCount: meta.pageCount },
    });
    saved.push({ id: meta.id, name: meta.name, pageCount: meta.pageCount });
  }

  return NextResponse.json({ files: saved }, { status: 201 });
}

getCurrentUser and db stand for your own authentication and data access (here in Prisma style). Replace them with yours.

4. Open a document

To have a document read, create a link in the name of the person who will read it. Their name and email will be burned into every page. Choose a short duration for one-off use (30 minutes for a team member) and a longer one for an assignment (7 days for a translator).

app/documents/actions.ts
"use server";

import { requireStaff } from "@/lib/auth";
import { db } from "@/lib/db";
import { createViewLink } from "@/lib/secure-docs";

/** Opens a document for a team member, for 30 minutes. */
export async function openDocument(documentId: string): Promise<string> {
  const staff = await requireStaff();
  const doc = await db.document.findUniqueOrThrow({ where: { id: documentId } });

  const link = await createViewLink(doc.vaultId, { name: staff.name, email: staff.email }, 30 * 60);
  if (!link) throw new Error("Could not create the view link");

  // Option A: return link.url (hosted viewer).
  // Option B: stay in the application, with the token only passing through your server.
  return `${process.env.APP_URL}/documents/view/${link.token}`;
}

5. The viewer page

This page runs on the server on every request: it reads the link's metadata, passes on the viewer's IP, checks that the signed-in user is the intended recipient, then renders the viewer. The token only appears in your own site's URL.

app/documents/view/[token]/page.tsx
import type { Metadata } from "next";
import { headers } from "next/headers";
import { notFound } from "next/navigation";

import { SecureViewer } from "@/components/secure-viewer";
import { getCurrentUser } from "@/lib/auth";
import { clientIp, getView } from "@/lib/secure-docs";

export const dynamic = "force-dynamic";

export const metadata: Metadata = {
  title: "Confidential document",
  robots: { index: false, follow: false },
  // The token is in the URL: it must never be sent in a Referer header.
  referrer: "no-referrer",
};

export default async function DocumentViewPage({ params }: { params: { token: string } }) {
  const user = await getCurrentUser();
  const view = await getView(params.token, clientIp(headers()));

  // The link is only valid for the person it was issued to.
  if (!view || !user || user.email.toLowerCase() !== view.viewerEmail.toLowerCase()) notFound();

  return (
    <SecureViewer
      token={params.token}
      fileName={view.fileName}
      pageCount={view.pageCount}
      viewerName={view.viewerName}
      expiresAt={view.expiresAt}
    />
  );
}

The image proxy

The browser requests each page from your application, which fetches it from secure-docs and streams it back without storing it. The headers prevent caching, prevent the content from being interpreted as anything other than an image, and stop the token from leaking through the Referer header.

app/documents/view/[token]/pages/[n]/route.ts
import { NextResponse } from "next/server";

import { clientIp, getViewPage } from "@/lib/secure-docs";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** One watermarked page, proxied from secure-docs with the viewer's IP. */
export async function GET(request: Request, { params }: { params: { token: string; n: string } }) {
  const page = await getViewPage(params.token, Number(params.n), clientIp(request.headers));
  if (!page?.body) return NextResponse.json({ error: "Invalid or expired link." }, { status: 404 });

  return new Response(page.body, {
    headers: {
      "Content-Type": "image/jpeg",
      "Cache-Control": "private, no-store",
      "X-Content-Type-Options": "nosniff",
      "Referrer-Policy": "no-referrer",
      "X-Robots-Tag": "noindex, nofollow",
    },
  });
}

6. The viewer component

A sketch of the viewer, to dress up with your own styles. It:

  • draws each page on a <canvas>, which removes the “Save image as” option;
  • only loads a page when it approaches the visible area (IntersectionObserver): each render takes about one second on the server, and a page that is never displayed is never rendered or logged;
  • blocks the context menu, copy, selection, drag and drop, and the Ctrl/Cmd+S, P, C, A shortcuts;
  • blurs the document as soon as the window loses focus, which most screenshot tools cause;
  • disappears entirely when printed, thanks to @media print { display: none }.
components/secure-viewer.tsx
"use client";

import { useEffect, useRef, useState } from "react";

interface Props {
  token: string;
  fileName: string;
  pageCount: number;
  viewerName: string;
  expiresAt: string;
}

/**
 * View-only document viewer. No web page can prevent a screenshot: the real
 * protection is the watermark, burned in on the server. This component adds
 * deterrents: canvas rendering (no "Save image as"), blocked shortcuts and
 * context menu, blur as soon as the window loses focus.
 */
export function SecureViewer({ token, fileName, pageCount, viewerName, expiresAt }: Props) {
  const [hidden, setHidden] = useState(false);

  useEffect(() => {
    const hide = () => setHidden(true);
    const show = () => setHidden(document.hidden || !document.hasFocus());
    const block = (e: Event) => e.preventDefault();
    const onKey = (e: KeyboardEvent) => {
      const key = e.key.toLowerCase();
      if ((e.ctrlKey || e.metaKey) && ["s", "p", "c", "a"].includes(key)) e.preventDefault();
      if (key === "printscreen") hide();
    };

    window.addEventListener("blur", hide);
    window.addEventListener("focus", show);
    window.addEventListener("beforeprint", hide);
    window.addEventListener("keydown", onKey, true);
    document.addEventListener("visibilitychange", show);
    document.addEventListener("contextmenu", block);
    document.addEventListener("copy", block);
    document.addEventListener("dragstart", block);
    document.addEventListener("selectstart", block);
    return () => {
      window.removeEventListener("blur", hide);
      window.removeEventListener("focus", show);
      window.removeEventListener("beforeprint", hide);
      window.removeEventListener("keydown", onKey, true);
      document.removeEventListener("visibilitychange", show);
      document.removeEventListener("contextmenu", block);
      document.removeEventListener("copy", block);
      document.removeEventListener("dragstart", block);
      document.removeEventListener("selectstart", block);
    };
  }, []);

  return (
    <div className="secure-viewer select-none">
      <style>{`
        @media print { .secure-viewer { display: none !important; } }
        .secure-viewer { -webkit-touch-callout: none; user-select: none; }
      `}</style>

      <header>
        <p>{fileName}</p>
        <p>
          View only · {viewerName} · until{" "}
          {new Date(expiresAt).toLocaleString("en-US", { timeZone: "Europe/Paris" })}
        </p>
      </header>

      <div style={hidden ? { filter: "blur(24px)", pointerEvents: "none" } : undefined}>
        {Array.from({ length: pageCount }, (_, i) => (
          <PageCanvas key={i} token={token} page={i + 1} />
        ))}
      </div>

      {hidden && (
        <button type="button" onClick={() => setHidden(false)} style={{ position: "fixed", inset: 0 }}>
          Document hidden. Click to resume reading.
        </button>
      )}
    </div>
  );
}

/** Loads a page as it nears the viewport, then draws it on a canvas. */
function PageCanvas({ token, page }: { token: string; page: number }) {
  const ref = useRef<HTMLCanvasElement>(null);
  const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas || state !== "idle") return;

    const observer = new IntersectionObserver(
      async ([entry]) => {
        if (!entry?.isIntersecting) return;
        observer.disconnect();
        setState("loading");
        try {
          const res = await fetch(`/documents/view/${token}/pages/${page}`, { cache: "no-store" });
          if (!res.ok) throw new Error(String(res.status));
          const bitmap = await createImageBitmap(await res.blob());
          canvas.width = bitmap.width;
          canvas.height = bitmap.height;
          canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
          bitmap.close();
          setState("done");
        } catch {
          setState("error");
        }
      },
      // Preloads about one page ahead: each render takes ~1 s.
      { rootMargin: "800px 0px" },
    );
    observer.observe(canvas);
    return () => observer.disconnect();
  }, [token, page, state]);

  return (
    <figure>
      <canvas
        ref={ref}
        aria-label={`Page ${page}`}
        style={{ display: "block", width: "100%", height: "auto", aspectRatio: state === "done" ? undefined : "1 / 1.414" }}
      />
      <figcaption>{state === "error" ? "Page unavailable, please reload." : `Page ${page}`}</figcaption>
    </figure>
  );
}

7. Deliver a downloadable document

The result of your work (here, the signed translation) is uploaded with kind: "delivery". It is the only type of file the API will return in full.

After approval
// After the translation is approved, on the admin side:
const meta = await uploadDocument(pdf, "certified-translation.pdf", "application/pdf", "delivery");
if (meta) {
  await db.order.update({ where: { id: orderId }, data: { status: "delivered", deliveryVaultId: meta.id } });
}

The API doesn't know your clients: your server must check that the signed-in user owns the order before calling GET /v1/files/:id/download. Pass their email in x-viewer-email so it shows up in the access log.

app/orders/[id]/download/route.ts
import { NextResponse } from "next/server";

import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { downloadDelivery } from "@/lib/secure-docs";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** The delivered document, only for the client who owns the order. */
export async function GET(_request: Request, { params }: { params: { id: string } }) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Sign-in required." }, { status: 401 });

  // 1. Check ownership in YOUR database, before any call to secure-docs.
  const order = await db.order.findFirst({ where: { id: params.id, customerId: user.id, status: "delivered" } });
  if (!order?.deliveryVaultId) return NextResponse.json({ error: "Not found." }, { status: 404 });

  // 2. Only then, fetch the file. The email is recorded in the access log.
  const file = await downloadDelivery(order.deliveryVaultId, user.email);
  if (!file) return NextResponse.json({ error: "Download unavailable." }, { status: 503 });

  const filename = encodeURIComponent(file.name);
  return new Response(new Uint8Array(file.content), {
    headers: {
      "Content-Type": file.contentType,
      "Content-Disposition": `attachment; filename*=UTF-8''${filename}`,
      "Cache-Control": "private, no-store",
      "X-Content-Type-Options": "nosniff",
      "Referrer-Policy": "no-referrer",
    },
  });
}

If a source document ID reaches this route by mistake, the API responds with 403 and downloadDelivery returns null: a source document cannot be downloaded, even through a bug on your side.

Checklist

  • The key exists only in the server's environment variables.
  • Your database contains only secure-docs IDs, no files or file URLs.
  • Every route checks the user before calling the API.
  • The viewer's IP is passed in x-viewer-ip for GET /v1/views/:token and every page.
  • Tokens are redacted from your logs and viewer pages are served with Referrer-Policy: no-referrer.