Secure File Download API¶
Breadcrumbs: Documentation > Guides > API > File Download
This guide explains the secure file download system for serving PDFs and other downloadable files.
Table of Contents¶
- Overview
- Architecture
- Implementation
- Security Features
- Supported File Types
- Usage Examples
- Error Handling
- Best Practices
Overview¶
The file download API provides secure, validated downloads for portfolio files like resumes, documents, and certificates.
Features:
- Security: Validates file paths to prevent directory traversal
- Type checking: Only allows specified file types
- Proper headers: Sets correct Content-Type and Content-Disposition
- Error handling: Graceful 404 for missing files
- SEO friendly: Included in sitemap
Current usage:
- Resume download:
/download/resume.pdf - Certificates, portfolios, etc.
Architecture¶
URL Structure¶
Examples:
/download/resume.pdf→public/downloads/resume.pdf/download/certificate.pdf→public/downloads/certificate.pdf/download/portfolio.pdf→public/downloads/portfolio.pdf
File Storage¶
Why public/downloads/?
- Organized structure
- Easy to manage
- Protected by API route (not directly accessible without validation)
Route Handler¶
Implementation¶
Basic Structure¶
// app/download/[filename]/route.ts
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(request: NextRequest, { params }: { params: { filename: string } }) {
const { filename } = params;
// Validate and serve file
const filePath = path.join(process.cwd(), "public/downloads", filename);
if (!fs.existsSync(filePath)) {
return new NextResponse("File not found", { status: 404 });
}
const fileBuffer = fs.readFileSync(filePath);
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
Full Implementation with Security¶
// app/download/[filename]/route.ts
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
// Allowed file extensions
const ALLOWED_EXTENSIONS = [".pdf", ".docx", ".doc", ".txt"];
// Maximum file size (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export async function GET(request: NextRequest, { params }: { params: { filename: string } }) {
try {
const { filename } = params;
// Security: Validate filename
if (!filename || filename.includes("..") || filename.includes("/")) {
return new NextResponse("Invalid filename", { status: 400 });
}
// Check file extension
const ext = path.extname(filename).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return new NextResponse("File type not allowed", { status: 403 });
}
// Build file path
const downloadsDir = path.join(process.cwd(), "public/downloads");
const filePath = path.join(downloadsDir, filename);
// Ensure file is within downloads directory (prevent traversal)
const normalizedPath = path.normalize(filePath);
if (!normalizedPath.startsWith(downloadsDir)) {
return new NextResponse("Access denied", { status: 403 });
}
// Check if file exists
if (!fs.existsSync(filePath)) {
return new NextResponse("File not found", { status: 404 });
}
// Check file size
const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE) {
return new NextResponse("File too large", { status: 413 });
}
// Read file
const fileBuffer = fs.readFileSync(filePath);
// Determine content type
const contentType = getContentType(ext);
// Return file with proper headers
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": stats.size.toString(),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error serving file:", error);
return new NextResponse("Internal server error", { status: 500 });
}
}
function getContentType(extension: string): string {
const contentTypes: Record<string, string> = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc": "application/msword",
".txt": "text/plain",
};
return contentTypes[extension] || "application/octet-stream";
}
Security Features¶
1. Path Traversal Prevention¶
Problem:
Attacker tries to access files outside downloads directory.
Solution:
// Reject filenames with .. or /
if (filename.includes("..") || filename.includes("/")) {
return new NextResponse("Invalid filename", { status: 400 });
}
// Verify normalized path is within downloads directory
const normalizedPath = path.normalize(filePath);
if (!normalizedPath.startsWith(downloadsDir)) {
return new NextResponse("Access denied", { status: 403 });
}
2. File Type Validation¶
Problem:
Attacker tries to serve executable files.
Solution:
const ALLOWED_EXTENSIONS = [".pdf", ".docx", ".doc", ".txt"];
const ext = path.extname(filename).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return new NextResponse("File type not allowed", { status: 403 });
}
3. File Size Limits¶
Problem: Serving very large files can cause memory issues.
Solution:
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE) {
return new NextResponse("File too large", { status: 413 });
}
4. Proper Error Handling¶
Don't expose system information:
// ❌ BAD: Exposes internal paths
catch (error) {
return new NextResponse(error.message, { status: 500 });
}
// ✅ GOOD: Generic error message
catch (error) {
console.error('Error serving file:', error);
return new NextResponse('Internal server error', { status: 500 });
}
Supported File Types¶
Current Configuration¶
Adding New File Types¶
Step 1: Add extension to allow list
Step 2: Add content type mapping
function getContentType(extension: string): string {
const contentTypes: Record<string, string> = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc": "application/msword",
".txt": "text/plain",
".zip": "application/zip", // Add mapping
};
return contentTypes[extension] || "application/octet-stream";
}
Step 3: Update max file size if needed
Common Content Types¶
| Extension | Content-Type |
|---|---|
.pdf |
application/pdf |
.docx |
application/vnd.openxmlformats-officedocument.wordprocessingml.document |
.doc |
application/msword |
.txt |
text/plain |
.zip |
application/zip |
.jpg |
image/jpeg |
.png |
image/png |
.json |
application/json |
.csv |
text/csv |
.xlsx |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
.pptx |
application/vnd.openxmlformats-officedocument.presentationml.presentation |
Usage Examples¶
Example 1: Resume Download Link¶
// components/resume-button.tsx
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
export function ResumeButton() {
return (
<Button asChild>
<Link href="/download/resume.pdf" download>
<Download className="mr-2 h-4 w-4" />
Download Resume
</Link>
</Button>
);
}
Example 2: Multiple File Downloads¶
// components/downloads-section.tsx
const downloadableFiles = [
{ name: 'Resume', filename: 'resume.pdf', description: 'My latest resume' },
{ name: 'Portfolio', filename: 'portfolio.pdf', description: 'Full portfolio PDF' },
{ name: 'Certificate', filename: 'certificate.pdf', description: 'AWS Certificate' },
];
export function DownloadsSection() {
return (
<div className="grid gap-4 md:grid-cols-3">
{downloadableFiles.map((file) => (
<Card key={file.filename}>
<CardHeader>
<CardTitle>{file.name}</CardTitle>
<CardDescription>{file.description}</CardDescription>
</CardHeader>
<CardFooter>
<Button asChild variant="outline" size="sm">
<Link href={`/download/${file.filename}`} download>
Download
</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
);
}
Example 3: Programmatic Download¶
// Client component with download logic
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
export function DownloadWithTracking() {
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
// Track download
await fetch('/api/track', {
method: 'POST',
body: JSON.stringify({ event: 'resume_download' }),
});
// Trigger download
const link = document.createElement('a');
link.href = '/download/resume.pdf';
link.download = 'resume.pdf';
link.click();
} catch (error) {
console.error('Download failed:', error);
} finally {
setDownloading(false);
}
};
return (
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? 'Downloading...' : 'Download Resume'}
</Button>
);
}
Example 4: Dynamic Filename¶
// Generate filename with user name
export function PersonalizedDownload() {
const filename = `${siteConfig.author.name.replace(' ', '_')}_Resume.pdf`;
return (
<Button asChild>
<Link href="/download/resume.pdf" download={filename}>
Download Resume
</Link>
</Button>
);
}
Error Handling¶
Client-Side Error Handling¶
'use client';
export function DownloadButton({ filename }: { filename: string }) {
const handleDownload = async () => {
try {
const response = await fetch(`/download/${filename}`);
if (!response.ok) {
if (response.status === 404) {
alert('File not found. Please contact the site administrator.');
} else if (response.status === 403) {
alert('You do not have permission to download this file.');
} else {
alert('An error occurred while downloading the file.');
}
return;
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
alert('Failed to download file. Please try again.');
}
};
return <Button onClick={handleDownload}>Download</Button>;
}
Server-Side Error Logging¶
// app/download/[filename]/route.ts
try {
// File serving logic
} catch (error) {
// Log detailed error server-side
console.error("File download error:", {
filename: params.filename,
error: error instanceof Error ? error.message : "Unknown error",
stack: error instanceof Error ? error.stack : undefined,
timestamp: new Date().toISOString(),
});
// Return generic error to client
return new NextResponse("Internal server error", { status: 500 });
}
Best Practices¶
1. Organize Files¶
public/
└── downloads/
├── resumes/
│ ├── resume-en.pdf
│ └── resume-nl.pdf
├── certificates/
│ ├── aws-certificate.pdf
│ └── azure-certificate.pdf
└── portfolios/
└── portfolio-2024.pdf
Update route to support subdirectories:
// app/download/[...path]/route.ts
export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) {
const filePath = path.join(process.cwd(), "public/downloads", ...params.path);
// ... rest of logic
}
Access:
/download/resumes/resume-en.pdf/download/certificates/aws-certificate.pdf
2. Add to Sitemap¶
// app/sitemap.ts
export default async function sitemap() {
const downloads = [
{
url: `${baseUrl}/download/resume.pdf`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
];
return [...otherRoutes, ...downloads];
}
3. Set Proper Cache Headers¶
// For files that change frequently
'Cache-Control': 'public, max-age=3600' // 1 hour
// For files that rarely change
'Cache-Control': 'public, max-age=31536000, immutable' // 1 year
4. Add Analytics Tracking¶
// Track downloads in middleware or route
await fetch(analyticsEndpoint, {
method: "POST",
body: JSON.stringify({
event: "file_download",
filename: params.filename,
timestamp: new Date().toISOString(),
}),
});
5. Provide File Metadata¶
// lib/downloads.ts
export const downloadableFiles = [
{
filename: 'resume.pdf',
title: 'Resume',
description: 'My latest resume in PDF format',
size: '150 KB',
lastUpdated: '2024-02-09',
},
// ... more files
];
// Use in components
import { downloadableFiles } from '@/lib/downloads';
export function DownloadsList() {
return (
<ul>
{downloadableFiles.map((file) => (
<li key={file.filename}>
<Link href={`/download/${file.filename}`}>
{file.title} ({file.size}) - Updated {file.lastUpdated}
</Link>
</li>
))}
</ul>
);
}
6. Version Files¶
// Use date versioning
resume-2024-02.pdf
portfolio-v2.pdf
certificate-2024.pdf
// Or semantic versioning
resume-1.0.pdf
resume-1.1.pdf
7. Compress Large Files¶
# Compress PDFs
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/ebook \
-dNOPAUSE -dQUIET -dBATCH -sOutputFile=resume-compressed.pdf resume.pdf
# Or use online tools: smallpdf.com, ilovepdf.com
See Also¶
- Middleware Documentation - Request tracking
- LLMs.txt Documentation - AI agent discovery
- SEO Guide - SEO implementation
- Best Practices - Code patterns
Next Steps¶
- Add downloadable files to
public/downloads/ - Test download links
- Add to navigation/UI
- Include in sitemap
- Set up analytics tracking
- Monitor download metrics
Last Updated: February 2026
Maintainers: Simon Stijnen
Questions? Open an issue on GitHub