Whipping up document magic: Your easy-bake recipe for Vue and Nutrient Web SDK 🧁
Table of contents
Remember the joy of your first Easy-Bake Oven? Well, you’re a developer, so you probably don’t, but please play along with me... so where were we? Yeah! That magical moment when you mixed simple ingredients, slid them into that tiny compartment, and Ta-da — out came something delicious? Well, grab your developer apron because you’re about to recreate that same nostalgic (low-key not so nostalgic) experience with Vue and Nutrient’s Web SDK and Document Engine!
What you’re building
By the end of this tutorial, you’ll have a complete document processing application that lets users:
- Upload PDF files through a drag-and-drop interface
- View and annotate PDFs with a full-featured viewer
- Share documents via unique URLs
You’ll use Vue 3 for the frontend, Nutrient Document Engine for processing, and Nutrient Web SDK for viewing. A mini Express backend will handle authentication and file uploads.
Architecture flow — Browser uploads PDF → Express backend → Document Engine processes → Web SDK displays
Prerequisites
Before you start, make sure you have:
- Node.js — Version 18 or higher
- Docker Desktop — For running Document Engine locally
- Basic Vue knowledge — Familiarity with components and routing
- Time estimate — About 30–45 minutes to complete
The secret sauce: What you’re cooking
Just like how that childhood oven revolutionized tiny kitchens, you’re revolutionizing the document in your web apps. Your recipe combines three simple ingredients to create something extraordinary:
- Vue 3 (your trusty mixing bowl)
- Nutrient Web SDK (the secret spice that makes everything taste better)
- Nutrient Document Engine (your magical oven that transforms documents into anything you can imagine)
Step 1: Setting up your kitchen (project creation) 👩🍳
Start from scratch! Create a fresh Vue 3 project using Vite(opens in a new tab):
npm create vite@latest nutrient-web-de-app -- --template vueNavigate to your project and install the dependencies:
cd nutrient-web-de-appnpm install # or use yarn 'cause you're fancyStep 2: Adding a few special ingredients 🥄
Now add Vue Router(opens in a new tab) for navigation and set up your mini backend structure. You’ll need routing to navigate between the upload page and document viewer:
# Add Vue Router for navigation.npm install vue-router
# Create your mini backend directory.mkdir mini-backendcd mini-backend
# Initialize the mini backend.npm init -y
# Add mini backend dependencies.npm install express jsonwebtoken fs cors multer form-data node-fetch
# Go back to the main project.cd ..Why a mini backend? — Document Engine requires server-side authentication for security. Your Express server will generate JSON Web Tokens (JWTs) and handle file uploads safely.
Step 3: Configuring your base project files 📋
Set up the foundation of your Vue application with configuration files and scripts.
Updating package.json with helpful scripts
Edit your main package.json file to add mini backend management scripts:
{ "name": "nutrient-web-de-app", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", "start:backend": "cd mini-backend && docker compose up -d && node server.js", "stop:backend": "cd mini-backend && docker compose down" }, "dependencies": { "vue": "^3.5.13", "vue-router": "^4.5.1" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.3", "vite": "^6.2.4", "vite-plugin-vue-devtools": "^7.7.2" }}These scripts let you start and stop both Docker and your backend server with a single command.
Updating index.html to include Nutrient SDK
Nutrient Web SDK needs to load from the CDN before your Vue app initializes. Replace the contents of index.html:
<!DOCTYPE html><html lang=""> <head> <meta charset="UTF-8"> <link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Nutrient - Document Engine Vue</title> </head> <body> <div id="app"></div> <script src="https://cdn.cloud.pspdfkit.com/pspdfkit-web@1.10.0/nutrient-viewer.js"></script> <script type="module" src="/src/main.js"></script> </body></html>Updating your Vite configuration
Configure Vite to use path aliases, making imports cleaner. Replace vite.config.js:
import { fileURLToPath, URL } from 'node:url'import { defineConfig } from 'vite'import vue from '@vitejs/plugin-vue'import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({ plugins: [ vue(), vueDevTools(), ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, },})The @ alias lets you write @/components/FileUpload.vue instead of ../../components/FileUpload.vue.
Step 4: Setting up Vue Router and base components 🧭
Now you’ll configure routing and create the minimal app component structure.
Setting up your main App.vue
Replace src/App.vue with this clean boilerplate:
<script setup>// App.vue — Clean and simple.</script>
<template> <router-view /></template>
<style>* { margin: 0; padding: 0; box-sizing: border-box;}
body { font-family: Arial, sans-serif;}
#app { min-height: 100vh; display: flex;}</style>Updating main.js
Replace src/main.js:
import './assets/main.css'
import { createApp } from 'vue'import App from './App.vue'import router from './router'
const app = createApp(App)app.use(router)app.mount('#app')Configuring your router
Replace src/router/index.js:
import { createRouter, createWebHistory } from 'vue-router'import FileUpload from '@/components/FileUpload.vue'import NutrientViewer from '@/components/NutrientViewer.vue'
const router = createRouter({ history: createWebHistory(), routes: [ { path: '/upload', name: 'upload', component: FileUpload }, { path: '/document/:documentId', name: 'NutrientViewer', component: NutrientViewer }, { path: '/', redirect: '/upload' } ]})
export default routerThis creates three routes: the upload page (home), the document viewer (dynamic route), and a redirect from root to upload.
Step 5: Creating the upload interface (the mixing bowl) 📤
Now for the fun part: creating a beautiful drag-and-drop upload interface. This component will handle file selection, uploading to your backend, and navigation to the viewer.
Create src/components/FileUpload.vue:
<script setup>import { ref } from 'vue'import { useRouter } from 'vue-router'
const router = useRouter()const selectedFile = ref(null)const isUploading = ref(false)const uploadError = ref(null)
const handleFileSelect = (event) => { const target = event.target if (target.files && target.files[0]) { selectedFile.value = target.files[0] uploadError.value = null }}
const uploadFile = async () => { if (!selectedFile.value) return
try { isUploading.value = true uploadError.value = null
// Create form data for the upload. const formData = new FormData() formData.append('file', selectedFile.value)
// Upload to your mini backend. const uploadResponse = await fetch('http://localhost:3001/api/upload', { method: 'POST', body: formData })
if (!uploadResponse.ok) { throw new Error('Upload failed') }
const result = await uploadResponse.json() const docId = result.documentId
// Navigate to the document viewer — like taking your cake out of the oven! router.push(`/document/${docId}`)
} catch (error) { uploadError.value = error.message } finally { isUploading.value = false }}</script>
<template> <div class="upload-container"> <div class="upload-card"> <div class="upload-header"> <svg class="upload-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14,2 14,8 20,8"/> <line x1="16" y1="13" x2="8" y2="13"/> <line x1="16" y1="17" x2="8" y2="17"/> <polyline points="10,9 9,9 8,9"/> </svg> <h2 class="upload-title">Upload PDF document</h2> <p class="upload-subtitle">Upload your PDF to get started with Nutrient</p> </div>
<div class="file-input-wrapper"> <div class="file-input-zone" :class="{ 'uploading': isUploading }"> <div v-if="!selectedFile" class="upload-prompt"> <span class="upload-primary">Choose a PDF file</span> <span class="upload-secondary">or drag and drop it here</span> </div>
<div v-else class="file-selected"> <svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14,2 14,8 20,8"/> </svg> <div class="file-info"> <span class="file-name">{{ selectedFile.name }}</span> <span class="file-size">{{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB</span> </div> <button v-if="!isUploading" @click="selectedFile = null" class="remove-file"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"> <line x1="18" y1="6" x2="6" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/> </svg> </button> </div>
<input type="file" accept=".pdf" @change="handleFileSelect" :disabled="isUploading" class="file-input" > </div> </div>
<button @click="uploadFile" :disabled="!selectedFile || isUploading" class="upload-btn" > <svg v-if="isUploading" class="loading-spinner" viewBox="0 0 24 24"> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/> <path d="m12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="2"/> </svg> <span>{{ isUploading ? 'Uploading document...' : 'Upload to Nutrient' }}</span> </button>
<div v-if="uploadError" class="error-message"> <svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <circle cx="12" cy="12" r="10"/> <line x1="15" y1="9" x2="9" y2="15"/> <line x1="9" y1="9" x2="15" y2="15"/> </svg> <span>{{ uploadError }}</span> </div> </div> </div></template>
<style scoped>/* Container and card styling */.upload-container { display: flex; justify-content: center; align-items: center; width: 100vw; height: 100vh; background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;}
.upload-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); width: 90vw; max-width: 600px;}
/* Header section */.upload-header { text-align: center; margin-bottom: 2.5rem;}
.upload-logo { width: 4rem; height: 4rem; color: #3b82f6; margin-bottom: 1rem;}
.upload-title { font-size: 2rem; font-weight: 700; color: #f8fafc; margin-bottom: 0.5rem;}
.upload-subtitle { color: #94a3b8; font-size: 1rem;}
/* File input zone */.file-input-zone { position: relative; border: 2px dashed #475569; border-radius: 0.75rem; padding: 4rem 2rem; background: #0f172a; cursor: pointer; margin-bottom: 2rem; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease;}
.file-input-zone:hover { border-color: #3b82f6; background: #1e293b;}
.file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer;}
/* Upload prompt and file selection */.upload-prompt { display: flex; flex-direction: column; align-items: center; gap: 0.25rem;}
.upload-primary { font-weight: 600; color: #f8fafc; font-size: 1.25rem;}
.upload-secondary { color: #94a3b8;}
.file-selected { display: flex; align-items: center; gap: 1rem; padding: 1.5rem; background: #334155; border-radius: 0.5rem; border: 1px solid #475569; width: 100%;}
.file-icon { width: 3rem; height: 3rem; color: #3b82f6;}
.file-info { flex: 1; display: flex; flex-direction: column; gap: 0.5rem;}
.file-name { font-weight: 600; color: #f8fafc; word-break: break-all;}
.file-size { color: #94a3b8; font-size: 0.875rem;}
/* Buttons */.remove-file { padding: 0.75rem; background: #dc2626; border: none; border-radius: 0.5rem; cursor: pointer; transition: background 0.2s;}
.remove-file:hover { background: #b91c1c;}
.remove-file svg { width: 1.25rem; height: 1.25rem;}
.upload-btn { width: 100%; padding: 1.25rem 2rem; background: linear-gradient(135deg, #3b82f6, #1d4ed8); color: white; border: none; border-radius: 0.75rem; font-weight: 700; font-size: 1.125rem; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 0.75rem; transition: all 0.3s ease;}
.upload-btn:hover:not(:disabled) { background: linear-gradient(135deg, #2563eb, #1e40af); transform: translateY(-1px); box-shadow: 0 20px 25px -5px rgba(59, 130, 246, 0.3);}
.upload-btn:disabled { background: #475569; cursor: not-allowed;}
/* Loading and error states */.loading-spinner { width: 1.5rem; height: 1.5rem; animation: spin 1s linear infinite;}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); }}
.error-message { display: flex; align-items: center; gap: 0.75rem; padding: 1.25rem; background: #7f1d1d; border: 1px solid #dc2626; border-radius: 0.75rem; margin-top: 1.5rem; color: #fecaca;}
.error-icon { width: 1.5rem; height: 1.5rem; color: #dc2626;}</style>The component features a modern gradient background, file size display, loading states, and error handling. The styles use Tailwind's color palette for consistency.
Step 6: Creating the document viewer (the final presentation) 📄
Once the file is uploaded, you need a viewer to display it. This component handles the connection to Document Engine and loads the Nutrient Web SDK viewer.
Create src/components/NutrientViewer.vue:
<script setup>import { onMounted, onUnmounted, useTemplateRef, computed } from "vue";import { useRoute } from "vue-router";
const containerRef = useTemplateRef("container");const route = useRoute();const { NutrientViewer } = window;
// Get the document ID from the route.const documentId = computed(() => { return route.params.documentId;});
async function loadViewer() { try { if (!documentId.value) return;
// Get a JWT token for this document. const response = await fetch("http://localhost:3001/api/token", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ documentId: documentId.value, }), });
const { jwt } = await response.json(); const container = containerRef.value;
// Load the Nutrient Viewer — the moment of truth! if (NutrientViewer) { await NutrientViewer.load({ container, documentId: documentId.value, authPayload: { jwt }, instant: true, serverUrl: "http://localhost:5000/", }); } } catch (error) { console.error("Failed to load viewer:", error); }}
onMounted(() => { const container = containerRef.value; if (container && NutrientViewer) { loadViewer(); }});
onUnmounted(() => { const container = containerRef.value; if (container && NutrientViewer) { NutrientViewer.unload(container); }});</script>
<template> <div class="wrapper"> <div id="NutrientViewer" ref="container" style="height: 100vh; width: 100%;"></div> </div></template>
<style scoped>.wrapper { width: 100%; height: 100vh;}</style>This component fetches a JWT token from your backend and then initializes the Nutrient Web SDK viewer with that token for secure document access.
Step 7: Building your mini backend (the oven controls) ⚙️
Your frontend is complete! Now it’s time to build the backend that ties everything together. The backend handles JWT token generation, file uploads, and communication with Document Engine.
Why JWT? — Document Engine uses JWT tokens to verify that requests are authorized. This prevents unauthorized access to documents.
Creating the Express server
Create mini-backend/server.js:
import express from "express";import jwt from "jsonwebtoken";import fs from "fs";import cors from "cors";import multer from "multer";import FormData from "form-data";import fetch from "node-fetch";
const app = express();app.use(express.json());app.use(cors());
const upload = multer({ storage: multer.memoryStorage() });const privateKey = fs.readFileSync("keys/jwt_private.pem");
// JWT token generation endpoint.app.post("/api/token", (req, res) => { const { documentId } = req.body;
if (!documentId) { return res.status(400).json({ error: "documentId is required" }); }
const token = jwt.sign( { document_id: documentId, permissions: ["read", "write"], }, privateKey, { algorithm: "RS256", expiresIn: "1h", } );
res.json({ jwt: token });});
// File upload endpoint — sends PDF to Document Engine.app.post("/api/upload", upload.single('file'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); }
// Generate a unique document ID. const documentId = 'DOC_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Prepare the document for Document Engine. const formData = new FormData(); formData.append('pdf-file-from-multipart', req.file.buffer, { filename: req.file.originalname, contentType: 'application/pdf' });
const instructions = { instructions: { parts: [{ file: 'pdf-file-from-multipart' }], actions: [], output: { metadata: { title: req.file.originalname.replace('.pdf', ''), author: 'Document Author' }, type: 'pdf' } }, document_id: documentId, title: req.file.originalname.replace('.pdf', ''), overwrite_existing_document: true };
formData.append('instructions', JSON.stringify(instructions.instructions)); formData.append('document_id', instructions.document_id); formData.append('title', instructions.title); formData.append('overwrite_existing_document', instructions.overwrite_existing_document.toString());
// Send to Document Engine. const response = await fetch('http://localhost:5000/api/documents', { method: 'POST', headers: { 'Authorization': 'Token token=secret', ...formData.getHeaders() }, body: formData });
if (!response.ok) { const errorText = await response.text(); console.error('Document Engine error:', response.status, errorText); throw new Error(`Document Engine error: ${response.status} - ${errorText}`); }
const result = await response.json(); const returnedDocumentId = result.data?.document_id || documentId;
res.json({ success: true, documentId: returnedDocumentId, title: result.data?.title || req.file.originalname, result: result });
} catch (error) { console.error("Upload error:", error.message); res.status(500).json({ error: error.message }); }});
app.get("/", (req, res) => { res.send("✅ Mini-backend is running.");});
app.listen(3001, () => { console.log("Mini-backend server running on port 3001");});This server provides two key endpoints: /api/token for JWT generation, and /api/upload for handling file uploads to Document Engine.
Creating the Docker Compose configuration
Document Engine runs as a Docker container with PostgreSQL. Add mini-backend/docker-compose.yml:
version: "3.8"
services: document_engine: image: pspdfkit/document-engine:@1.13.0 environment: PGUSER: de-user PGPASSWORD: password PGDATABASE: document-engine PGHOST: db PGPORT: 5432 API_AUTH_TOKEN: secret SECRET_KEY_BASE: secret-key-base JWT_PUBLIC_KEY: | -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2gzhmJ9TDanEzWdP1WG+ 0Ecwbe7f3bv6e5UUpvcT5q68IQJKP47AQdBAnSlFVi4X9SaurbWoXdS6jpmPpk24 QvitzLNFphHdwjFBelTAOa6taZrSusoFvrtK9x5xsW4zzt/bkpUraNx82Z8MwLwr t6HlY7dgO9+xBAabj4t1d2t+0HS8O/ed3CB6T2lj6S8AbLDSEFc9ScO6Uc1XJlSo rgyJJSPCpNhSq3AubEZ1wMS1iEtgAzTPRDsQv50qWIbn634HLWxTP/UH6YNJBwzt 3O6q29kTtjXlMGXCvin37PyX4Jy1IiPFwJm45aWJGKSfVGMDojTJbuUtM+8P9Rrn AwIDAQAB -----END PUBLIC KEY----- JWT_ALGORITHM: RS256 DASHBOARD_USERNAME: dashboard DASHBOARD_PASSWORD: secret ports: - 5000:5000 depends_on: - db
db: image: postgres:16 environment: POSTGRES_USER: de-user POSTGRES_PASSWORD: password POSTGRES_DB: document-engine POSTGRES_INITDB_ARGS: --data-checksums PGDATA: /var/lib/postgresql/data/pgdata volumes: - pgdata:/var/lib/postgresql/data
volumes: pgdata:Why Docker Compose? — Document Engine requires PostgreSQL for document storage. Docker Compose lets you run both services together with a single command.
Setting up JWT keys
Create the keys directory and add the private key:
# Go to mini backend directory.cd mini-backend
# Create keys directory.mkdir keys
# Go back to main project.cd ..Now create mini-backend/keys/jwt_private.pem with this content:
-----BEGIN RSA PRIVATE KEY-----MIIEpQIBAAKCAQEA2gzhmJ9TDanEzWdP1WG+0Ecwbe7f3bv6e5UUpvcT5q68IQJKP47AQdBAnSlFVi4X9SaurbWoXdS6jpmPpk24QvitzLNFphHdwjFBelTAOa6taZrSusoFvrtK9x5xsW4zzt/bkpUraNx82Z8MwLwrt6HlY7dgO9+xBAabj4t1d2t+0HS8O/ed3CB6T2lj6S8AbLDSEFc9ScO6Uc1XJlSorgyJJSPCpNhSq3AubEZ1wMS1iEtgAzTPRDsQv50qWIbn634HLWxTP/UH6YNJBwzt3O6q29kTtjXlMGXCvin37PyX4Jy1IiPFwJm45aWJGKSfVGMDojTJbuUtM+8P9RrnAwIDAQABAoIBAQDSKxhGw0qKINhQIwQP5+bDWdqUG2orjsQf2dHOHNhRwJoUNuDZ4f3tcYzV7rGmH0d4Q5CaXj2qMyCd0eVjpgW0h3z9kM3RA+d7BX7XKlkdQABliZUT9SUUcfIPvohXPKEzBRHed2kf6WVtXKAuJTD+Dk3LjzRygWldOAE4mnLeZjU61kxPYriynyre+44Gpsgy37Tj25MAmVCYFlotr/1WZx6bg3HIyFRGxnoJ1zU1MkGxwS4IsrQwOpWEHBiD5nvo54hF5I00NHj/ccz+MwpgGdjyl02IGCy1fF+Q5SYyH86DG52Mgn8VI9dseGmanLGcgNvrdJFILoJRSZW7gQoBAoGBAP+D6ZmRF7EqPNMypEHQ5qHHDMvil3mhNQJyIC5rhhl/nn063wnmzhg96109hVh4zUAj3Rmjb9WqPiW7KBMJJdnEPjmZ/NOXKmgjs2BF+c8oiLQyTQmlxB7LnptvBDi8MnEd3uemfxNuZc+2siuSzgditshNru8xPG2Sn99JC271AoGBANp2xj5EfdlqNLd11paLOtJ7dfREgc+8FxQCiKSxbaOlVXNk0DW1w4+zLnFohj2m/wRrbBIzSL+eufoQ9y4BT/AA+ln4qxOpC0isOGK5SxwIjB6OHhCuP8L3anj1IFYM+NX0Xr1/qdZHKulgbS49cq+TDpB74WyKLLnsvQFyINMXAoGABR5+cp4ujFUdTNnp4out4zXasscCY+Rv7HGe5W8wC5i78yRXzZn7LQ8ohQCziDc7XXqadmYI2o4DmrvqLJ91S6yb1omYQCD6L4XvlREx1Q2p13pegr/4cul/bvvFaOGUXSHNEnUKfLgsgAHYBfl1+T3oDZFI3O/ulv9mBpIvEXUCgYEApeRnqcUM49o4ac/7wZm8czT5XyHeiUbFJ5a8+IMbRJc6CkRVr1N1S1u/OrMqrQpwwIRqLm/vIEOB6hiT+sVYVGIJueSQ1H8baHYO4zjdhk4fSNyWjAgltwF2Qp+xjGaRVrcYckHNUD/+n/VvMxvKSPUcrC7GAUvzpsPUypJFxsUCgYEA6GuP6M2zIhCYYeB2iLRD4ZHw92RfjikaYmB0++T0y2TVrStlzXHlc8H6tJWNchtHH30nfLCj9WIMb/cODpm/DrzlSigHffo3+5XUpD/2nSrcFKESw4Xsa4GXoAxqU44w4Mckg2E19b2MrcNkV9eWAyTACbEO4oFcZcSZOCKj8Fw=-----END RSA PRIVATE KEY-----Note — If you'd like to create your own JWT keys for production use, follow this guide.
Updating the mini backend package.json
Create mini-backend/package.json:
{ "name": "nutrient-mini-backend", "version": "1.0.0", "type": "module", "main": "server.js", "dependencies": { "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "fs": "*", "cors": "^2.8.5", "multer": "^1.4.5", "form-data": "^4.0.0", "node-fetch": "^3.3.2" }}Almost there! You’ve completed all the setup. Now fire it up and test everything.
Step 8: Firing up your creation! 🔥
Time to see your easy-bake creation come to life!
Starting the mini backend
npm run start:backendThis will
- Start Docker services (Document Engine and PostgreSQL)
- Start your Express server on port 3001
You should see: “Mini-backend server running on port 3001.”
Starting the Vue development server
In a new terminal, run:
npm run devYour Vue app will be available at http://localhost:5173
Step 9: Testing your document magic ✨
- Navigate to
http://localhost:5173. - Upload a PDF using the beautiful interface.
- Watch the magic as you’re automatically redirected to the viewer.
- Enjoy your full-featured PDF viewer with annotations, bookmarks, and more!
Pro baker tips 👩🍳
Direct document access
Share specific documents by navigating to:
http://localhost:5173/document/{documentId}Your API endpoints
POST /api/token— Generate JWT tokensPOST /api/upload— Upload PDFs to Document EngineGET /— Health check
Clean up when done
npm run stop:backendThis stops both Docker containers and your Express server.
Troubleshooting
Port already in use — If port 3001 or 5000 is already in use, run:
# Find and kill the process using the port.lsof -ti:3001 | xargs kill -9lsof -ti:5000 | xargs kill -9Docker not starting — Make sure Docker Desktop is running and you have enough resources allocated (at least 4 GB RAM).
CORS errors — Check that your backend is running on port 3001 and the frontend on 5173. The CORS middleware is configured for all origins.
JWT errors — Ensure the JWT_PUBLIC_KEY in docker-compose.yml matches the private key you created.
Ready for production 🍽️
Build for production:
npm run buildProduction checklist
- Generate your own JWT key pair (don’t use the example keys)
- Set proper CORS origins instead of allowing all
- Use environment variables for sensitive data
- Deploy Document Engine to a production server
- Add error logging and monitoring
The recipe recap
Congratulations! You’ve just created a complete document processing application with:
- Drag-and-drop document uploads — A modern, intuitive file upload interface
- Secure JWT authentication — Server-side token generation for Document Engine
- Full-featured document viewing — Annotations, bookmarks, and more via Nutrient Web SDK
- A beautiful, responsive UI — Tailwind-inspired gradients and animations
- A Docker-powered backend — Document Engine and PostgreSQL running together
The architecture you built
- User uploads PDF through Vue frontend
- Express backend receives file and sends to Document Engine
- Document Engine processes and stores PDF
- Backend generates JWT token
- Frontend loads Web SDK viewer with token
- User views and annotates document
Just like that first mini oven creation sparked a lifetime love of cooking, this recipe might be the start of your document processing empire!
Happy baking, and may your documents always render perfectly! 🎂