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 vue
Navigate to your project and install the dependencies:
cd nutrient-web-de-app
npm install # or use yarn 'cause you're fancy
Step 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-backend
cd 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.16.1/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 router
This 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.16.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+0Ecwbe7f3bv6e5UUpvcT5q68IQJK
P47AQdBAnSlFVi4X9SaurbWoXdS6jpmPpk24QvitzLNFphHdwjFBelTAOa6taZrS
usoFvrtK9x5xsW4zzt/bkpUraNx82Z8MwLwrt6HlY7dgO9+xBAabj4t1d2t+0HS8
O/ed3CB6T2lj6S8AbLDSEFc9ScO6Uc1XJlSorgyJJSPCpNhSq3AubEZ1wMS1iEtg
AzTPRDsQv50qWIbn634HLWxTP/UH6YNJBwzt3O6q29kTtjXlMGXCvin37PyX4Jy1
IiPFwJm45aWJGKSfVGMDojTJbuUtM+8P9RrnAwIDAQABAoIBAQDSKxhGw0qKINhQ
IwQP5+bDWdqUG2orjsQf2dHOHNhRwJoUNuDZ4f3tcYzV7rGmH0d4Q5CaXj2qMyCd
0eVjpgW0h3z9kM3RA+d7BX7XKlkdQABliZUT9SUUcfIPvohXPKEzBRHed2kf6WVt
XKAuJTD+Dk3LjzRygWldOAE4mnLeZjU61kxPYriynyre+44Gpsgy37Tj25MAmVCY
Flotr/1WZx6bg3HIyFRGxnoJ1zU1MkGxwS4IsrQwOpWEHBiD5nvo54hF5I00NHj/
ccz+MwpgGdjyl02IGCy1fF+Q5SYyH86DG52Mgn8VI9dseGmanLGcgNvrdJFILoJR
SZW7gQoBAoGBAP+D6ZmRF7EqPNMypEHQ5qHHDMvil3mhNQJyIC5rhhl/nn063wnm
zhg96109hVh4zUAj3Rmjb9WqPiW7KBMJJdnEPjmZ/NOXKmgjs2BF+c8oiLQyTQml
xB7LnptvBDi8MnEd3uemfxNuZc+2siuSzgditshNru8xPG2Sn99JC271AoGBANp2
xj5EfdlqNLd11paLOtJ7dfREgc+8FxQCiKSxbaOlVXNk0DW1w4+zLnFohj2m/wRr
bBIzSL+eufoQ9y4BT/AA+ln4qxOpC0isOGK5SxwIjB6OHhCuP8L3anj1IFYM+NX0
Xr1/qdZHKulgbS49cq+TDpB74WyKLLnsvQFyINMXAoGABR5+cp4ujFUdTNnp4out
4zXasscCY+Rv7HGe5W8wC5i78yRXzZn7LQ8ohQCziDc7XXqadmYI2o4DmrvqLJ91
S6yb1omYQCD6L4XvlREx1Q2p13pegr/4cul/bvvFaOGUXSHNEnUKfLgsgAHYBfl1
+T3oDZFI3O/ulv9mBpIvEXUCgYEApeRnqcUM49o4ac/7wZm8czT5XyHeiUbFJ5a8
+IMbRJc6CkRVr1N1S1u/OrMqrQpwwIRqLm/vIEOB6hiT+sVYVGIJueSQ1H8baHYO
4zjdhk4fSNyWjAgltwF2Qp+xjGaRVrcYckHNUD/+n/VvMxvKSPUcrC7GAUvzpsPU
ypJFxsUCgYEA6GuP6M2zIhCYYeB2iLRD4ZHw92RfjikaYmB0++T0y2TVrStlzXHl
c8H6tJWNchtHH30nfLCj9WIMb/cODpm/DrzlSigHffo3+5XUpD/2nSrcFKESw4Xs
a4GXoAxqU44w4Mckg2E19b2MrcNkV9eWAyTACbEO4oFcZcSZOCKj8Fw=
-----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
This 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:
Your 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
This 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 -9
lsof -ti:5000 | xargs kill -9
Docker 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:
Production 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! 🎂


























