ScreenshotNeo

BlogHTML to image & PDF

How to Split a PDF in a Next.js App

Build a Next.js Route Handler that validates an uploaded PDF, extracts selected pages with pdf-lib, and returns a new PDF safely.

By the ScreenshotNeo team4 October 20269 min read

To split a PDF in a Next.js App Router app, receive the upload in a Route Handler, validate the file and requested page numbers, then use pdf-lib to copy those pages into a new PDF and return its bytes. The example below extracts one continuous range. The same approach can produce multiple outputs by creating one new document per selection.

This guide uses the App Router and TypeScript. Route Handlers are public HTTP endpoints and can read request bodies with Web Request methods such as formData(). Next.js backend-for-frontend guidance recommends validating incoming content type and size, applying timeouts, and avoiding sensitive data in logs and client-facing errors.

1. Install pdf-lib

Install pdf-lib using your package manager. It is pure JavaScript with no native dependencies and supports Node.js as well as browsers. See the pdf-lib documentation and its PDFDocument API reference.

npm install pdf-lib

2. Add a PDF splitting Route Handler

Create app/api/split/route.ts. This endpoint accepts a multipart form with a file field and startPage and endPage fields. Page numbers in the form are 1-based, as users expect; pdf-lib page indices are 0-based, so the code converts them after validation.

import { PDFDocument } from 'pdf-lib';
import { NextResponse } from 'next/server';

export const runtime = 'nodejs';

// Choose a limit appropriate to the deployment target. This example uses 20 MiB.
const MAX_FILE_BYTES = 20 * 1024 * 1024;

function error(message: string, status: number) {
  return NextResponse.json({ error: message }, { status });
}

export async function POST(request: Request) {
  let form: FormData;
  try {
    form = await request.formData();
  } catch {
    return error('Send a multipart form with a PDF file.', 400);
  }

  const file = form.get('file');
  const startText = form.get('startPage');
  const endText = form.get('endPage');

  if (!(file instanceof File)) {
    return error('The file field is required.', 400);
  }
  if (file.size === 0) {
    return error('The uploaded file is empty.', 400);
  }
  if (file.size > MAX_FILE_BYTES) {
    return error('The PDF exceeds this endpoint’s upload limit.', 413);
  }

  // MIME type and filename are useful hints, not proof of file contents.
  if (file.type && file.type !== 'application/pdf') {
    return error('Upload a file with the PDF media type.', 415);
  }
  if (typeof startText !== 'string' || typeof endText !== 'string') {
    return error('Provide startPage and endPage.', 400);
  }
  if (!/^\d+$/.test(startText) || !/^\d+$/.test(endText)) {
    return error('Page numbers must be positive whole numbers.', 400);
  }

  const startPage = Number(startText);
  const endPage = Number(endText);
  if (!Number.isSafeInteger(startPage) || !Number.isSafeInteger(endPage) || startPage < 1 || endPage < startPage) {
    return error('Page range is invalid.', 400);
  }

  try {
    const inputBytes = await file.arrayBuffer();
    // The MIME type can be absent or spoofed. Parsing the bytes is the substantive check.
    const source = await PDFDocument.load(inputBytes);
    const pageCount = source.getPageCount();

    if (endPage > pageCount) {
      return error(`The PDF has ${pageCount} pages; endPage is out of range.`, 400);
    }

    const output = await PDFDocument.create();
    const zeroBasedIndices = Array.from(
      { length: endPage - startPage + 1 },
      (_, offset) => startPage - 1 + offset,
    );
    const pages = await output.copyPages(source, zeroBasedIndices);
    for (const page of pages) output.addPage(page);

    const bytes = await output.save();
    return new Response(bytes as unknown as BodyInit, {
      status: 200,
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': 'attachment; filename="split.pdf"',
        'Cache-Control': 'no-store',
      },
    });
  } catch {
    // Do not return parser internals or uploaded content to the caller.
    return error('Could not read this PDF. It may be malformed, encrypted, or unsupported.', 422);
  }
}

The 20 MiB value is an example policy, not a Next.js or hosting limit. Check your host’s current request-body, memory, and execution limits and set the application cap below the practical ceiling. If a host rejects the body before this handler runs, a route-level check cannot override that platform limit.

3. Call the endpoint from a form

A browser form can send the file and range as multipart data. The response is a PDF blob; create a temporary link to download it and revoke the object URL afterward.

async function splitPdf(file: File, startPage: number, endPage: number) {
  const form = new FormData();
  form.append('file', file);
  form.append('startPage', String(startPage));
  form.append('endPage', String(endPage));

  const response = await fetch('/api/split', { method: 'POST', body: form });
  if (!response.ok) {
    const payload = await response.json().catch(() => ({}));
    throw new Error(payload.error ?? `PDF split failed (${response.status})`);
  }

  const blob = await response.blob();
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = 'split.pdf';
  link.click();
  URL.revokeObjectURL(url);
}

Do not manually set the request’s Content-Type when sending FormData; the browser supplies the multipart boundary.

4. Extract specific, non-contiguous pages

For selections such as pages 1, 3, and 8, accept a list of page numbers, validate every entry against the source page count, convert to zero-based indices, and pass that array to copyPages. Preserve the input order unless the product explicitly offers sorting.

const selectedPages = [1, 3, 8]; // User-facing, 1-based page numbers
if (selectedPages.some((page) => !Number.isSafeInteger(page) || page < 1 || page > source.getPageCount())) {
  throw new Error('A selected page is out of range.');
}
const copied = await output.copyPages(source, selectedPages.map((page) => page - 1));
for (const page of copied) output.addPage(page);

For a user-supplied list, also cap the number of entries, reject duplicates if duplicates are not meaningful in your interface, and validate the list’s syntax before conversion. Do not accept arbitrary expressions or evaluate input as JavaScript.

5. Produce multiple PDF files

To split a source into several PDFs, define the output groups explicitly, for example [[1, 2], [3, 4]]. For each group, create a fresh PDFDocument, copy its selected pages, and save it. A single Route Handler response can return only one response body, so choose a delivery format: return one PDF per request, create a ZIP archive, or return an asynchronous job result with download links. ZIP packaging requires an additional library; select one compatible with your runtime and account for the added memory and output size. There is no universal hosting response-size limit, so check the chosen deployment platform.

6. Decide whether splitting belongs in the browser or server

Consideration Browser processing Server processing
Where bytes are processed Can stay on the device if the implementation does not upload the file. Upload reaches your application infrastructure.
Controls Convenient for a local-only utility; client validation can be changed by the user. Central place for authentication, authorization, validation, and audit policy.
Device constraints Large PDFs can consume memory or make low-powered devices unresponsive. Uses server memory and execution time; concurrency can increase resource use.
Delivery Generate a blob and offer a local download. Return the bytes, or store them deliberately and issue a controlled download.

pdf-lib supports both environments, but neither is universally faster or safer. For private files where avoiding upload is a requirement, keep the entire processing path in the browser and verify memory behavior on supported devices. If splitting on the server, use authentication where needed, rate-limit public endpoints, and avoid logging document contents or unnecessary identifiers.

7. Security, reliability, and deployment checks

  • Validate before parsing. Check that a file exists, is non-empty, is under an application size limit, and that selections are valid. A filename extension or supplied MIME type alone does not establish that bytes form a valid PDF.
  • Bound resource use. Limit upload size, page count, selection count, concurrent work, and request duration according to your service and host. A small compressed PDF can still require substantial parsing memory.
  • Protect the route. Route Handlers are public endpoints. Add authentication and authorization for restricted users, and rate limiting where unauthenticated processing could be abused.
  • Handle errors without leaking details. Return actionable generic messages; keep sensitive parser details and document contents out of client responses and logs.
  • Avoid implicit persistence. The example does not write the upload to disk. If you store files or generated output, define access controls, retention, and cleanup explicitly.
  • Check the deployment runtime. Some providers run handlers as lambdas where requests do not share local state, writable filesystem access may be absent, and long jobs may be terminated. Do not depend on a file written in one request being available in another.
  • Plan for larger jobs. For large inputs or multiple outputs, consider direct upload to dedicated storage and an asynchronous job flow, if supported by your host. Inspect current request, memory, storage, and timeout limits before release.

8. Troubleshooting

Symptom Likely cause Fix
400 response: multipart form required The request is not encoded as multipart/form-data. Send a browser FormData body or construct multipart data in your client. Do not set the boundary header yourself.
415 response The upload declares a non-PDF media type. Choose a PDF file. Treat media type as a preliminary check; parsing still determines whether the content can be read.
413 response or host-level rejection The upload exceeds the application or platform body-size limit. Reduce the input, adjust the application limit within host constraints, or use a storage-backed upload flow.
422 response The bytes could not be loaded as a supported PDF; encryption or malformed content may be involved. Ask for a valid, supported PDF and verify compatibility with the installed pdf-lib version. Do not claim every encrypted or malformed document is supported.
Page range rejected Pages are 1-based in the UI, but indices are 0-based in the library, or the end page exceeds the document length. Validate against getPageCount(), require positive integers, then subtract one when building indices.
Request times out Input parsing and serialization exceed the host’s execution window. Reduce file size or selected work, verify current host time limits, or move processing to a background job or suitable service.
Memory pressure or process termination Input bytes, parsed objects, copied pages, and output bytes coexist in memory. Set conservative limits, avoid parallel jobs on a constrained instance, and measure behavior on the actual runtime and target documents.
Download opens as JSON or has a wrong filename The client did not check the status or the response headers are missing. Check response.ok; successful output should use application/pdf and a suitable Content-Disposition.

9. Performance and cost notes

Splitting requires loading and parsing the source, copying selected pages, and serializing a new document. Memory use depends on the input and document structure; no fixed speed or size guarantee follows from the library’s runtime support. Keep the work bounded, process only the selected pages, and avoid running many large jobs concurrently. Server processing costs application compute and may incur storage or transfer costs if you retain or serve generated files. Browser processing shifts that resource use to the visitor’s device. Measure with representative documents on the actual hosting plan and devices rather than relying on an assumed maximum.

10. Or skip the browser setup

If your workflow also needs page previews or screenshots of web pages related to a document, ScreenshotNeo provides a website screenshot API and MCP server. A screenshot API does not split PDF files; it can help capture a web page in the same developer workflow.

One GET request captures a URL. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
  • Cookie banners are accepted and removed before capture, along with known newsletter popups and chat widgets.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed; response headers report the page verdict and billing status.
  • An MCP server gives AI agents tools for screenshots, page information, and PDF capture.
  • The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.

11. FAQ

Can I split a PDF without uploading it?

Yes. Run pdf-lib in the browser and keep the file processing client-side. Ensure no code path uploads the source, and test memory and responsiveness on the devices you support.

Does copying pages preserve every feature of the original PDF?

Do not assume that all encrypted, signed, malformed, or form-heavy documents behave identically. Validate the document types your application accepts against the installed library version and test the resulting output for your use case.

Should I return one PDF or several?

Return a PDF when the user selected one range. For multiple groups, use separate requests, a ZIP, or an asynchronous workflow with downloads; choose based on output size and hosting constraints.

Can a Route Handler save the upload for a later request?

Not reliably across deployment environments. Some serverless handlers lack shared writable storage. Use dedicated storage with explicit access and retention controls if later retrieval is required.