{"id":39987,"date":"2026-04-14T07:00:00","date_gmt":"2026-04-14T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=39987"},"modified":"2026-04-13T15:44:37","modified_gmt":"2026-04-13T22:44:37","slug":"secure-document-portals-watermarking","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking","title":{"rendered":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><ul>\n<li>\n<a href=\"https:\/\/secure-pdf-portal.vercel.app\/\"><strong>Live Demo<\/strong><\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\"><strong>Source Code<\/strong><\/a>\n<\/li>\n<\/ul>\n<p>PDFs are simple to create and share, but don\u2019t offer much control. Once a raw file is downloaded, there\u2019s no trail of who accessed it or whether it\u2019s been shared. In this guide, you\u2019ll build a secure portal that transforms static PDFs into dynamic, traceable assets using <strong>Next.js<\/strong>, <strong>Cloudinary<\/strong>, and <strong>pdf-lib<\/strong>.<\/p>\n<p>Instead of just storing files, this system handles the entire lifecycle: instant previews, user-specific watermarking, and multi-format delivery.<\/p>\n<p>This will be your stack:<\/p>\n<ul>\n<li>Next.js for Application framework and server-side logic.<\/li>\n<li>Cloudinary handles uploads, page-to-image previews, and format exports.<\/li>\n<li>\n<code>pdf-lib<\/code> injects permanent watermarks into the PDF on the server.<\/li>\n<li>\n<code>shadcn\/ui<\/code> for a clean, responsive portal interface.<\/li>\n<\/ul>\n<h2>The Workflow: From Upload to Traceable Delivery<\/h2>\n<p>Most tools stop at the upload. This project focuses on the next step of making files fast to view and traceable.<\/p>\n<ol>\n<li>\n<strong>Upload.<\/strong> Send PDFs to Cloudinary via the Upload Widget.<\/li>\n<li>\n<strong>Preview.<\/strong> Instantly view any page as an image without downloading the full file.<\/li>\n<li>\n<strong>Watermark.<\/strong> Apply a unique user ID to both the UI preview and the final download.<\/li>\n<li>\n<strong>Deliver.<\/strong> Generate optimized browser links, export specific page ranges, or convert pages to JPG\/PNG.<\/li>\n<\/ol>\n<h2>The Limitations of Plain Delivery<\/h2>\n<p>Standard file links are blunt and create three friction points. The first is a lack of visible accountability, if the PDF is identical for everyone. The second being heavy previews, because users shouldn\u2019t have to download a large file just to check a single page. Lastly, a plain URL doesn\u2019t allow for page ranges or image exports like JPG or PNG.<\/p>\n<h3>The Shift: Static Storage vs. Secure Delivery<\/h3>\n<p>It\u2019s time to move away from treating PDFs as dead assets.<\/p>\n<p><strong>The Traditional Approach:<\/strong><\/p>\n<p>Upload file \u2192 Share raw link \u2192 Same file for everyone \u2192 Single download format \u2192 Zero visibility<\/p>\n<p><strong>The Secure Approach:<\/strong><\/p>\n<p>Upload file \u2192 Preview pages first \u2192 Watermark by user \u2192 Multiple delivery options \u2192 Traceable output<\/p>\n<h3>Why Cloudinary and Watermarking?<\/h3>\n<p>A standard storage layer treats a PDF like an opaque file in a bucket. To show a preview or export a page, you\u2019d usually need to build custom rendering logic and store extra assets.<\/p>\n<p>Cloudinary changes the architecture by treating PDFs as a <strong>media pipeline<\/strong>. Instead of just storing the file, you\u2019ll use it to derive multiple outputs from a single source:<\/p>\n<ul>\n<li>\n<strong>Instant previews.<\/strong> Pages are rendered as fast-loading images via URL transformations, eliminating the need for manual thumbnail generation.<\/li>\n<li>\n<strong>Multi-format export.<\/strong> One uploaded PDF can instantly serve as a JPG, a PNG, or a specific page range without duplicating files.<\/li>\n<li>\n<strong>Clean uploads.<\/strong> Using <strong>Upload Presets<\/strong>, you\u2019ll centralize folder destinations and format rules in the Cloudinary dashboard rather than scattering them throughout our code.<\/li>\n<\/ul>\n<h3>The Hybrid Approach: Cloudinary + <code>pdf-lib<\/code><\/h3>\n<p>While Cloudinary powers the delivery and preview experience, you\u2019ll use <a href=\"https:\/\/pdf-lib.js.org\"><code>pdf-lib<\/code><\/a> for the final download. This ensures the user-based watermark is permanently baked into the PDF on the server, creating a technical and psychological barrier to careless sharing.<\/p>\n<p>By combining these tools, you\u2019re building a delivery system where the file is flexible, previewable, and traceable.<\/p>\n<h2>Project Setup<\/h2>\n<p>Keep the architecture lean with <strong>Next.js<\/strong> for the app logic, <strong>Cloudinary<\/strong> for the media pipeline, and <strong><code>pdf-lib<\/code><\/strong> for server-side watermarking.<\/p>\n<h3>1. Initialize and Install<\/h3>\n<p>Create a fresh Next.js project and install the necessary dependencies for the document workflow and UI.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">npx<\/span> <span class=\"hljs-selector-tag\">create-next-app<\/span><span class=\"hljs-keyword\">@latest<\/span> secure-pdf-portal\ncd secure-pdf-portal\n\n# Install core logic <span class=\"hljs-keyword\">and<\/span> UI components\nnpm install cloudinary next-cloudinary pdf-lib\nnpx shadcn@latest init -t next\nnpx shadcn@latest add button card input badge separator label\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>2. Project Structure<\/h3>\n<p>To keep the logic easy to follow, organize the project by responsibility:<\/p>\n<ul>\n<li>\n<code>src\/lib\/cloudinary\/<\/code> for SDK configuration and URL generation helpers.<\/li>\n<li>\n<code>src\/lib\/pdf\/<\/code> for server-side watermarking logic using <code>pdf-lib<\/code>.<\/li>\n<li>\n<code>src\/components\/demo\/<\/code> for modular UI pieces (Upload, Preview, and Delivery actions).<\/li>\n<li>**<code>src\/types\/<\/code> as shared TypeScript definitions for our uploaded assets.<\/li>\n<\/ul>\n<h3>3. Configuration and Environment Variables<\/h3>\n<p>Add your Cloudinary credentials to your <code>.env<\/code> file. Use public variables for the client-side upload widget and secret keys for the server-side SDK.<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name\nNEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=your_preset\nCLOUDINARY_API_KEY=your_api_key\nCLOUDINARY_API_SECRET=your_api_secret\n<\/code><\/span><\/pre>\n<p>Then, initialize the Cloudinary SDK in <a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/lib\/cloudinary\/config.ts\"><code>src\/lib\/cloudinary\/config.ts<\/code><\/a>:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">import<\/span> { v2 <span class=\"hljs-keyword\">as<\/span> cloudinary } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"cloudinary\"<\/span>\n\ncloudinary.config({\n  <span class=\"hljs-attr\">cloud_name<\/span>: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,\n  <span class=\"hljs-attr\">api_key<\/span>: process.env.CLOUDINARY_API_KEY,\n  <span class=\"hljs-attr\">api_secret<\/span>: process.env.CLOUDINARY_API_SECRET,\n  <span class=\"hljs-attr\">secure<\/span>: <span class=\"hljs-literal\">true<\/span>,\n})\n\n<span class=\"hljs-keyword\">export<\/span> { cloudinary }\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>4. Define the Data Shape<\/h3>\n<p>Since the PDF data flows from the upload widget to the preview and delivery components, you should define a shared <code>UploadedDocument<\/code> type to keep things consistent.<\/p>\n<p>In <a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/types\/document.ts\"><code>src\/types\/document.ts<\/code><\/a>, the uploaded asset shape looks like this:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> type UploadedDocument = {\n  <span class=\"hljs-attr\">publicId<\/span>: string\n  <span class=\"hljs-attr\">originalFilename<\/span>: string\n  <span class=\"hljs-attr\">format<\/span>: string\n  <span class=\"hljs-attr\">bytes<\/span>: number\n  <span class=\"hljs-attr\">pages<\/span>: number\n  <span class=\"hljs-attr\">secureUrl<\/span>: string\n  <span class=\"hljs-attr\">resourceType<\/span>: string\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By modularizing the UI and isolating the Cloudinary config, you\u2019re building a foundation that makes the actual implementation of the upload and watermarking flows straightforward.<\/p>\n<h2>Configuring the <a href=\"https:\/\/cloudinary.com\/documentation\/upload_presets\">Cloudinary Upload Preset<\/a><\/h2>\n<p>Before the widget can handle uploads, Cloudinary needs a <strong><a href=\"https:\/\/cloudinary.com\/documentation\/upload_presets\">preset<\/a><\/strong> \u2014 a central rule-book that defines where files go and what formats are allowed. This keeps your frontend code clean and ensures your portal doesn\u2019t end up with unsupported file types.<\/p>\n<h3>The Preset Configuration<\/h3>\n<p>For this project, use an <strong>Unsigned<\/strong> upload preset. While production systems might require signed uploads for maximum security, an unsigned preset is ideal for this workflow because it reduces friction while maintaining strict format guardrails.<\/p>\n<p>Key settings:<\/p>\n<ul>\n<li>Name: <code>secure_pdf_unsigned<\/code>\n<\/li>\n<li>Mode: Unsigned<\/li>\n<li>Folder: <code>secure-pdf-portal<\/code>\n<\/li>\n<li>Allowed Formats: <code>pdf<\/code> (strictly enforced)<\/li>\n<\/ul>\n<p><img decoding=\"async\" src=\"https:\/\/cloudinary-marketing-res.cloudinary.com\/image\/upload\/v1776119180\/blog-Secure_Document_Portals_With_Cloudinary_and_Next.js_Dynamic_PDF_Watermarking_and_Multi-Format_Delivery-1.png\" alt=\"the preset configuration\" loading=\"lazy\" class=\"c-transformed-asset\"  width=\"1916\" height=\"949\"\/><\/p>\n<p>Restricting the format to PDF at the preset level is critical. Our portal logic relies on PDF-specific features like page-to-image previews and server-side watermarking. By enforcing this rule in Cloudinary, you\u2019ll protect the application from processing incompatible assets.<\/p>\n<h3>Connecting the Preset to Next.js<\/h3>\n<p>To use the preset, ensure the name in your Cloudinary dashboard matches your <code>.env.local<\/code> file exactly. We then reference these values in <a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/lib\/cloudinary\/constants.ts\"><code>src\/lib\/cloudinary\/constants.ts<\/code><\/a> to keep the UI components focused on the interaction rather than the configuration.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> cloudinaryConfig = {\n  <span class=\"hljs-attr\">cloudName<\/span>: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME || <span class=\"hljs-string\">\"\"<\/span>,\n  <span class=\"hljs-attr\">uploadPreset<\/span>: process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET || <span class=\"hljs-string\">\"\"<\/span>,\n  <span class=\"hljs-attr\">folder<\/span>: <span class=\"hljs-string\">\"secure-pdf-portal\"<\/span>,\n} <span class=\"hljs-keyword\">as<\/span> <span class=\"hljs-keyword\">const<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>With the preset configured, the upload path is now focused, organized, and ready to feed the rest of the document workflow.<\/p>\n<h2>Uploading PDFs with the Cloudinary Upload Widget<\/h2>\n<p>The upload flow is the heart of the portal. Instead of building a custom file handler, we use the <strong>Cloudinary Upload Widget<\/strong> to manage file selection and progress. Once the upload finishes, the widget hands off metadata like the <code>publicId<\/code> and <code>pages<\/code> count; to the application state to drive the rest of the experience.<\/p>\n<h3>1. The Upload Engine<\/h3>\n<p>The core interaction happens within the <code>CldUploadWidget<\/code>. By setting <code>resourceType: &quot;image&quot;<\/code>, we unlock Cloudinary\u2019s ability to treat PDF pages as transformable image assets later in the build.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/components\/demo\/upload-pdf-button.tsx<\/span>\n\n&lt;CldUploadWidget\n  uploadPreset={cloudinaryConfig.uploadPreset}\n  options={{\n    clientAllowedFormats: &#91;<span class=\"hljs-string\">\"pdf\"<\/span>],\n    folder: cloudinaryConfig.folder,\n    multiple: <span class=\"hljs-keyword\">false<\/span>,\n    maxFiles: <span class=\"hljs-number\">1<\/span>,\n    resourceType: <span class=\"hljs-string\">\"image\"<\/span>, \n  }}\n  onSuccess={(result) =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> info = result?.info <span class=\"hljs-keyword\">as<\/span> any;\n    <span class=\"hljs-keyword\">if<\/span> (!info?.public_id) <span class=\"hljs-keyword\">return<\/span>;\n\n    onUploadSuccess({\n      publicId: info.public_id,\n      originalFilename: info.original_filename,\n      format: info.format,\n      bytes: info.bytes,\n      pages: info.pages,\n      secureUrl: info.secure_url,\n      resourceType: info.resource_type,\n    });\n  }}\n&gt;\n  {({ open }) =&gt; &lt;Button onClick={() =&gt; open()}&gt;Upload PDF&lt;\/Button&gt;}\n&lt;\/CldUploadWidget&gt;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<blockquote>\n<p>View the full component <a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/components\/demo\/upload-pdf-button.tsx\">here<\/a>.<\/p>\n<\/blockquote>\n<h3>2. Storing the Document in State<\/h3>\n<p>The upload button doesn\u2019t own the document experience; it simply returns the asset to the parent page. In <a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/components\/demo\/demo-page-client.tsx\"><code>src\/components\/demo\/demo-page-client.tsx<\/code><\/a>, you\u2019ll use React state to capture this data and reset the UI for the new file.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> &#91;<span class=\"hljs-built_in\">document<\/span>, setDocument] = useState&lt;UploadedDocument | <span class=\"hljs-literal\">null<\/span>&gt;(<span class=\"hljs-literal\">null<\/span>)\n\n<span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">handleUploadSuccess<\/span>(<span class=\"hljs-params\">nextDocument: UploadedDocument<\/span>) <\/span>{\n  setDocument(nextDocument)\n  setPreviewPage(<span class=\"hljs-number\">1<\/span>) <span class=\"hljs-comment\">\/\/ Reset to the first page<\/span>\n  setPageRange(<span class=\"hljs-string\">`1-<span class=\"hljs-subst\">${<span class=\"hljs-built_in\">Math<\/span>.min(nextDocument.pages || <span class=\"hljs-number\">1<\/span>, <span class=\"hljs-number\">2<\/span>)}<\/span>`<\/span>) <span class=\"hljs-comment\">\/\/ Default range<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>3. Making the UI Reactive<\/h3>\n<p>As soon as <code>setDocument<\/code> is called, the portal shifts from an empty state to an active workflow:<\/p>\n<ul>\n<li>The Document Info Card populates with the filename, size, and page count.<\/li>\n<li>The Preview Panel immediately renders the first page of the PDF.<\/li>\n<li>The Delivery Actions (Watermarking and Exports) become functional.<\/li>\n<\/ul>\n<p>This handoff ensures the application stays in sync with the Cloudinary asset, providing instant feedback and a polished user experience.<\/p>\n<h2>Rendering PDF Pages as Fast Preview Images<\/h2>\n<p>A document portal shouldn\u2019t make users guess what they uploaded. By rendering a PDF page as a JPG, you\u2019ll provide instant confirmation that the file is correct and the watermark is targeting the right area.<\/p>\n<p>Without Cloudinary, this would require manual rendering libraries or separate thumbnail storage. Here, the preview is just a <strong>delivery URL<\/strong>.<\/p>\n<h3>1. The URL Engine<\/h3>\n<p>The logic lives in a simple helper that generates a transformation URL. You\u2019ll use three key parameters:<\/p>\n<ul>\n<li>\n<code>pg_${page}<\/code> selects the specific PDF page to render.<\/li>\n<li>\n<code>f_jpg<\/code> tells Cloudinary to convert that page into a standard image format.<\/li>\n<li>\n<code>q_auto<\/code> automatically optimizes the image quality for fast loading.<\/li>\n<\/ul>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/lib\/cloudinary\/urls.ts<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getPdfPreviewImageUrl<\/span>(<span class=\"hljs-params\">publicId: string, page = <span class=\"hljs-number\">1<\/span><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME\n\n  <span class=\"hljs-keyword\">if<\/span> (!cloudName || !publicId) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">\"\"<\/span>\n\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`https:\/\/res.cloudinary.com\/<span class=\"hljs-subst\">${cloudName}<\/span>\/image\/upload\/pg_<span class=\"hljs-subst\">${page}<\/span>,f_jpg,q_auto\/<span class=\"hljs-subst\">${publicId}<\/span>.pdf`<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>2. Rendering the Preview in the UI<\/h3>\n<p>The preview panel uses the helper above to feed a Next.js <code>Image<\/code> component. You\u2019ll utilize <code>object-contain<\/code> to ensure that regardless of the PDF\u2019s aspect ratio (portrait vs. landscape), the entire page remains visible within the preview container.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\">\/\/ src\/components\/demo\/preview-panel.tsx\n\nconst previewUrl = document\n  ? getPdfPreviewImageUrl(document.publicId, previewPage)\n  : \"\"\n\n<span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"relative aspect-3\/4 w-full overflow-hidden rounded-lg border bg-muted\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Image<\/span>\n    <span class=\"hljs-attr\">src<\/span>=<span class=\"hljs-string\">{previewUrl}<\/span>\n    <span class=\"hljs-attr\">alt<\/span>=<span class=\"hljs-string\">{<\/span>`${<span class=\"hljs-attr\">document.originalFilename<\/span>} <span class=\"hljs-attr\">preview<\/span>`}\n    <span class=\"hljs-attr\">fill<\/span>\n    <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"object-contain\"<\/span>\n    <span class=\"hljs-attr\">sizes<\/span>=<span class=\"hljs-string\">\"(max-width: 640px) 100vw, (max-width: 1280px) 66vw, 50vw\"<\/span>\n    <span class=\"hljs-attr\">unoptimized<\/span>\n  \/&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>3. Interactive Page Selection<\/h3>\n<p>To let users navigate the document, wire a simple number input to the <code>previewPage<\/code> state and enforce boundaries so the user can\u2019t select a page index that doesn\u2019t exist in the document metadata.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/components\/demo\/delivery-options-form.tsx<\/span>\n\n&lt;Input\n  id=<span class=\"hljs-string\">\"preview-page\"<\/span>\n  type=<span class=\"hljs-string\">\"number\"<\/span>\n  min={<span class=\"hljs-number\">1<\/span>}\n  max={maxPage}\n  value={previewPage}\n  onChange={(event) =&gt; {\n    <span class=\"hljs-keyword\">const<\/span> nextValue = <span class=\"hljs-built_in\">Number<\/span>(event.target.value) || <span class=\"hljs-number\">1<\/span>\n    <span class=\"hljs-keyword\">const<\/span> safeValue = <span class=\"hljs-built_in\">Math<\/span>.min(<span class=\"hljs-built_in\">Math<\/span>.max(<span class=\"hljs-number\">1<\/span>, nextValue), maxPage)\n    onPreviewPageChange(safeValue)\n  }}\n\/&gt;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By the end of this step, the PDF is no longer a static file in a bucket, but an interactive asset. This sets the stage for the next section, where we\u2019ll overlay a dynamic watermark on this preview before generating the final secure document.<\/p>\n<h2>Adding Dynamic User Watermarks to the Preview<\/h2>\n<p>A preview without a watermark shows a document; a preview with one shows it in <strong>context<\/strong>. Before generating a real PDF on the server, you\u2019ll show a visual overlay in the UI. This provides instant feedback, allowing users to see exactly how the document is personalized before they download it.<\/p>\n<h3>1. Capturing User Identity<\/h3>\n<p>Use a simple input to capture a unique identifier (like a username or employee ID). This value is stored in a shared state so that both the preview panel and the final delivery logic stay in sync.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/components\/demo\/user-watermark-form.tsx<\/span>\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">UserWatermarkForm<\/span>(<span class=\"hljs-params\">{ userId, onUserIdChange }<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Input<\/span>\n      <span class=\"hljs-attr\">id<\/span>=<span class=\"hljs-string\">\"user-id\"<\/span>\n      <span class=\"hljs-attr\">placeholder<\/span>=<span class=\"hljs-string\">\"eugene_1024\"<\/span>\n      <span class=\"hljs-attr\">value<\/span>=<span class=\"hljs-string\">{userId}<\/span>\n      <span class=\"hljs-attr\">onChange<\/span>=<span class=\"hljs-string\">{(event)<\/span> =&gt;<\/span> onUserIdChange(event.target.value)}\n    \/&gt;<\/span>\n  );\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>By lifting this state to the parent page (<a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\/blob\/main\/src\/components\/demo\/demo-page-client.tsx\"><code>src\/components\/demo\/demo-page-client.tsx<\/code><\/a>), you\u2019ll ensure the ID is available globally:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> &#91;userId, setUserId] = useState(<span class=\"hljs-string\">\"eugene_1024\"<\/span>);\n\n<span class=\"hljs-comment\">\/\/ Shared between components<\/span>\n<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">UserWatermarkForm<\/span> <span class=\"hljs-attr\">userId<\/span>=<span class=\"hljs-string\">{userId}<\/span> <span class=\"hljs-attr\">onUserIdChange<\/span>=<span class=\"hljs-string\">{setUserId}<\/span> \/&gt;<\/span><\/span>\n<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">PreviewPanel<\/span> <span class=\"hljs-attr\">document<\/span>=<span class=\"hljs-string\">{document}<\/span> <span class=\"hljs-attr\">userId<\/span>=<span class=\"hljs-string\">{userId}<\/span> <span class=\"hljs-attr\">previewPage<\/span>=<span class=\"hljs-string\">{previewPage}<\/span> \/&gt;<\/span><\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>2. The Visual Overlay Pattern<\/h3>\n<p>The preview watermark is a CSS layer that sits above the rendered Cloudinary page image. It doesn\u2019t require a server round-trip, so it updates instantly as the user types.<\/p>\n<p>Use a rotated, repeated stack of text with low opacity to ensure it is visible without obstructing the document content.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ Inside src\/components\/demo\/preview-panel.tsx<\/span>\n\n<span class=\"hljs-keyword\">const<\/span> watermarkText = userId.trim() || <span class=\"hljs-string\">\"guest_user\"<\/span>;\n<span class=\"hljs-keyword\">const<\/span> watermarkRows = <span class=\"hljs-built_in\">Array<\/span>.from({ <span class=\"hljs-attr\">length<\/span>: <span class=\"hljs-number\">3<\/span> });\n\n<span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"pointer-events-none absolute inset-0 overflow-hidden\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">div<\/span> <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"absolute left-1\/2 top-1\/2 flex w-&#91;130%] -translate-x-1\/2 -translate-y-1\/2 -rotate-30 flex-col gap-14 sm:gap-20\"<\/span>&gt;<\/span>\n    {watermarkRows.map((_, index) =&gt; (\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">p<\/span>\n        <span class=\"hljs-attr\">key<\/span>=<span class=\"hljs-string\">{index}<\/span>\n        <span class=\"hljs-attr\">className<\/span>=<span class=\"hljs-string\">\"whitespace-nowrap text-center text-sm font-semibold uppercase tracking-&#91;0.3em] text-foreground\/12 sm:text-base\"<\/span>\n      &gt;<\/span>\n        {watermarkText} \u2022 {watermarkText} \u2022 {watermarkText}\n      <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">p<\/span>&gt;<\/span>\n    ))}\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">div<\/span>&gt;<\/span><\/span>;\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>The Two-Layer Strategy<\/h3>\n<p>This split is a key design choice for a responsive portal. A <strong>preview watermark<\/strong> is a fast UI overlay for visual confirmation. No PDF rewriting needed. A delivered watermark is a permanent modification generated on the server. A traceable, final document.<\/p>\n<p>This approach keeps the app feeling snappy while ensuring the final output remains secure and accountable.<\/p>\n<h2>Multi-Format Delivery with Cloudinary<\/h2>\n<p>A PDF isn\u2019t always the ideal final output. Sometimes a user needs a lightweight browser version, a single-page image for a quick share, or a specific page range. Cloudinary allows us to derive these outputs from a single <code>publicId<\/code> via delivery URLs, keeping our application architecture lean.<\/p>\n<h3>1. Optimized PDF Delivery<\/h3>\n<p>Not every document needs to be delivered at full resolution. For faster browser viewing or reduced bandwidth, you can generate an optimized PDF link using Cloudinary\u2019s <code>quality: &quot;auto&quot;<\/code> transformation.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/lib\/cloudinary\/download.ts<\/span>\n\n<span class=\"hljs-keyword\">import<\/span> { cloudinary } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">\"@\/lib\/cloudinary\/config\"<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getOptimizedPdfUrl<\/span>(<span class=\"hljs-params\">publicId: string, filename?: string<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> safeFilename = filename\n    ? filename.replace(<span class=\"hljs-regexp\">\/&#91;^\\w\\-]+\/g<\/span>, <span class=\"hljs-string\">\"-\"<\/span>).toLowerCase()\n    : <span class=\"hljs-string\">\"document\"<\/span>\n\n  <span class=\"hljs-keyword\">return<\/span> cloudinary.url(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${publicId}<\/span>.pdf`<\/span>, {\n    <span class=\"hljs-attr\">resource_type<\/span>: <span class=\"hljs-string\">\"image\"<\/span>,\n    <span class=\"hljs-attr\">type<\/span>: <span class=\"hljs-string\">\"upload\"<\/span>,\n    <span class=\"hljs-attr\">secure<\/span>: <span class=\"hljs-literal\">true<\/span>,\n    <span class=\"hljs-attr\">flags<\/span>: <span class=\"hljs-string\">`attachment:<span class=\"hljs-subst\">${safeFilename}<\/span>`<\/span>,\n    <span class=\"hljs-attr\">transformation<\/span>: &#91;{ <span class=\"hljs-attr\">quality<\/span>: <span class=\"hljs-string\">\"auto\"<\/span> }],\n  })\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>2. Watermarked Image Exports (JPG\/PNG)<\/h3>\n<p>Since Cloudinary treats PDF pages as transformable images, you can export specific pages as JPGs or PNGs. You can even apply a delivery watermark directly within the URL, avoiding the need for a separate image processing pipeline.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/lib\/cloudinary\/urls.ts<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getWatermarkedPdfPageImageUrl<\/span>(<span class=\"hljs-params\">\n  publicId: string,\n  userId: string,\n  page = <span class=\"hljs-number\">1<\/span>,\n  format: <span class=\"hljs-string\">\"jpg\"<\/span> | <span class=\"hljs-string\">\"png\"<\/span> = <span class=\"hljs-string\">\"jpg\"<\/span>\n<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME || <span class=\"hljs-string\">\"\"<\/span>;\n  <span class=\"hljs-keyword\">if<\/span> (!cloudName || !publicId) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">\"\"<\/span>;\n\n  <span class=\"hljs-keyword\">const<\/span> safeUserId = userId.trim() || <span class=\"hljs-string\">\"guest_user\"<\/span>;\n  <span class=\"hljs-keyword\">const<\/span> overlayText = <span class=\"hljs-built_in\">encodeURIComponent<\/span>(<span class=\"hljs-string\">`Downloaded by <span class=\"hljs-subst\">${safeUserId}<\/span>`<\/span>);\n  <span class=\"hljs-keyword\">const<\/span> textLayer = <span class=\"hljs-string\">`l_text:Arial_42_bold:<span class=\"hljs-subst\">${overlayText}<\/span>`<\/span>;\n\n  <span class=\"hljs-comment\">\/\/ pg_ selects page, f_ defines format, l_text adds the watermark<\/span>\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`https:\/\/res.cloudinary.com\/<span class=\"hljs-subst\">${cloudName}<\/span>\/image\/upload\/pg_<span class=\"hljs-subst\">${page}<\/span>,f_<span class=\"hljs-subst\">${format}<\/span>,q_auto\/<span class=\"hljs-subst\">${textLayer}<\/span>\/co_rgb:555555,o_28\/g_center,a_-30\/fl_layer_apply\/<span class=\"hljs-subst\">${publicId}<\/span>.pdf`<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>3. Delivering Selected Page Ranges<\/h3>\n<p>If a user only needs a subset of a document (e.g., pages 1-3 or specific pages like 2;4), you can request exactly that through the <code>pg<\/code> parameter. This reduces file weight and prevents over-sharing content.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-15\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-comment\">\/\/ src\/lib\/cloudinary\/urls.ts<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getPdfPageRangeUrl<\/span>(<span class=\"hljs-params\">publicId: string, pageRange: string<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> cloudName = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME || <span class=\"hljs-string\">\"\"<\/span>\n  <span class=\"hljs-keyword\">if<\/span> (!cloudName || !publicId || !pageRange.trim()) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">\"\"<\/span>\n\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`https:\/\/res.cloudinary.com\/<span class=\"hljs-subst\">${cloudName}<\/span>\/image\/upload\/pg_<span class=\"hljs-subst\">${pageRange}<\/span>\/<span class=\"hljs-subst\">${publicId}<\/span>.pdf`<\/span>\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-15\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>Why This Keeps the Build Light<\/h3>\n<p>The core advantage is <strong>derived media<\/strong>. By using URL-based logic, you\u2019ll avoid:<\/p>\n<ul>\n<li>Generating and storing separate JPG\/PNG files manually.<\/li>\n<li>Building a custom PDF splitting service.<\/li>\n<li>Managing a separate preview rendering service.<\/li>\n<\/ul>\n<p>The result is a highly flexible portal where the user can upload once and choose the output that fits their specific needs; whether it\u2019s a traceable, watermarked PDF or a quick-share image.<\/p>\n<h3>Testing the Workflow<\/h3>\n<p>To ensure the portal is production-ready, move through the application as a user would. Verify each touchpoint to confirm the data handoff is seamless.<\/p>\n<ul>\n<li>\n<strong>Upload.<\/strong> Confirm the widget restricts files to PDF and that the document info card updates immediately with the correct metadata.<\/li>\n<li>\n<strong>Preview.<\/strong> Verify that switching page numbers renders the correct page image instantly and respects the document\u2019s total page count.<\/li>\n<li>\n<strong>Visual watermark.<\/strong> Ensure the UI overlay updates in real-time as you type the User ID and remains visible across different preview pages.<\/li>\n<li>\n<strong>Real PDF delivery.<\/strong> Download the watermarked file. Every page should contain the permanent server-side stamp reflecting the current User ID.<\/li>\n<li>\n<strong>Multi-format exports.<\/strong> Test the JPG\/PNG exports and the selected page-range links to confirm Cloudinary is correctly deriving these from the source PDF.<\/li>\n<\/ul>\n<h3>What to Watch For<\/h3>\n<p>As you test, pay close attention to <strong>state consistency<\/strong>. Does the filename in the UI match the file in Cloudinary? Does the watermark in the preview match the final download? A reliable portal depends on these layers staying perfectly in sync.<\/p>\n<h3>Resources and Live Demo<\/h3>\n<p>You can explore the final build and review the full implementation through the links below:<\/p>\n<ul>\n<li>\n<a href=\"https:\/\/secure-pdf-portal.vercel.app\/\"><strong>Live Demo<\/strong><\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/github.com\/musebe\/secure-pdf-portal\"><strong>Source Code on GitHub<\/strong><\/a>\n<\/li>\n<\/ul>\n<h3>Final Thoughts: Beyond Basic Storage<\/h3>\n<p>A standard PDF upload flow is easy to build, but a secure delivery experience requires more intention. The strength of this project lies in its modularity:<\/p>\n<ul>\n<li>\n<strong>Cloudinary<\/strong> handles the heavy lifting of the media pipeline (uploads, previews, and format exports).<\/li>\n<li>\n<strong>Next.js<\/strong> manages the application shell and secure server-side routes.<\/li>\n<li>\n<strong>pdf-lib<\/strong> provides the specialized logic needed for permanent document modification.<\/li>\n<\/ul>\n<p>This foundation is built to scale. You can easily extend this portal by adding user authentication, audit logs for downloads, or role-based access to specific document folders.<\/p>\n<p>By treating the PDF as a dynamic asset rather than a static file, you\u2019ve created a system that\u2019s faster to preview, easier to deliver, and more accountable. If you\u2019re ready to start building, <a href=\"https:\/\/cloudinary.com\/users\/register_free\">sign up<\/a> for a free Cloudinary account today.<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"","protected":false},"author":87,"featured_media":39988,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[384,157,212,264],"class_list":["post-39987","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-delivery","tag-image-formats","tag-next-js","tag-security"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v25.6 (Yoast SEO v26.9) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-04-14T14:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA\" \/>\n\t<meta property=\"og:image:width\" content=\"2000\" \/>\n\t<meta property=\"og:image:height\" content=\"1100\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery\",\"datePublished\":\"2026-04-14T14:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\"},\"wordCount\":14,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA\",\"keywords\":[\"Delivery\",\"Image Formats\",\"Next.js\",\"Security\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\",\"url\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\",\"name\":\"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA\",\"datePublished\":\"2026-04-14T14:00:00+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA\",\"width\":2000,\"height\":1100},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/cloudinary.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking","og_locale":"en_US","og_type":"article","og_title":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery","og_url":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking","og_site_name":"Cloudinary Blog","article_published_time":"2026-04-14T14:00:00+00:00","og_image":[{"width":2000,"height":1100,"url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","type":"image\/jpeg"}],"author":"melindapham","twitter_card":"summary_large_image","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery","datePublished":"2026-04-14T14:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking"},"wordCount":14,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","keywords":["Delivery","Image Formats","Next.js","Security"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking","url":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking","name":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","datePublished":"2026-04-14T14:00:00+00:00","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","width":2000,"height":1100},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/secure-document-portals-watermarking#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Secure Document Portals With Cloudinary and Next.js, Dynamic PDF Watermarking, and Multi-Format Delivery"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1775775445\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers\/Blog_Automated_Editorial_Approval_Workflows_in_Cloudinary_DAM_for_Publishers.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/39987","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=39987"}],"version-history":[{"count":1,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/39987\/revisions"}],"predecessor-version":[{"id":39989,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/39987\/revisions\/39989"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/39988"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=39987"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=39987"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=39987"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}