Building an Online Resume Editor from Scratch: Next.js 16 + React 19 + Koa Full-Stack Practice (with Flicker-Free PDF Real-Time Preview)

前端工程

Keywords: Next.js 16 / React 19 / @react-pdf/renderer / PDF Real-Time Preview / Tiptap / Koa2 / Sequelize / SEO Engineering / i18n

Reading Time: ~25 min | Code Ratio: 40% | Audience: Senior Frontend & Full-Stack Engineers


Foreword

Building an "online resume editor" sounds like a CRUD project: fill in a form on the left, preview on the right, click a button to download a PDF.

That's what I thought too. Until I actually started building—and discovered one pitfall after another:

  • When the user types a single character, should the PDF on the right regenerate? Each regeneration takes ~300ms, and the resulting white flash completely destroys the UX;
  • The browser-printed PDF doesn't match the preview—fonts, line heights, and pagination are all messed up;
  • If a Chinese resume includes a few English words, @react-pdf/renderer breaks CJK characters at the wrong positions;
  • A single Chinese font TTF file easily exceeds 10MB, and loading the full set at startup kills first-screen performance;
  • Sharing a resume via an auto-incrementing ID means anyone can browse every resume on the site by simply incrementing id+1;
  • 7 templates × 13 modules × 4 languages—without proper abstraction, changing one thing breaks everything.

This article thoroughly explains the pitfalls encountered and the final solutions in BeautyResume (https://beautyresume.com)—a production-grade project. Every line of code is battle-tested and production-ready, not demo-level pseudocode.


I. Technology Choices: Why This Particular Stack

1.1 Overall Architecture

┌─────────────────────────────────────────────────────────┐
│                    Browser / Client                       │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │  Form Editor  │→ │ Redux Store  │→ │  PDF Preview   │  │
│  │   (Tiptap)   │  │ (RTK Slice)  │  │ (PDF.js Canvas)│  │
│  └──────────────┘  └──────────────┘  └───────────────┘  │
└──────────────────────────┬──────────────────────────────┘
                           │ HTTPS
┌──────────────────────────▼──────────────────────────────┐
│         Nginx (Reverse Proxy / Static Assets / Gzip)     │
└──────────┬────────────────────────────┬─────────────────┘
           │                            │
┌──────────▼──────────┐      ┌──────────▼─────────────────┐
│  Next.js 16 SSR     │      │   Koa 2 API Server         │
│  (standalone output)│      │   (PM2 Cluster Mode)        │
│  App Router         │      │   Session + CSRF + Rate Limiting │
└─────────────────────┘      └──────────┬─────────────────┘
                                        │
                            ┌───────────▼──────────┐
                            │  MySQL 8 + Sequelize │
                            │  Alibaba Cloud OSS   │
                            └──────────────────────┘

1.2 Tech Stack

Frontend (frontend/package.json):

Domain Choice Version Why
Framework Next.js 16.2.12 App Router + output: 'standalone', wins on both SEO and deployment size
UI Library React 19.2.3 Concurrent features + useTransition are essential for heavy re-rendering scenarios
State Redux Toolkit ^2.5.0 Resume data is a deeply nested object requiring fine-grained subscriptions
PDF Generation @react-pdf/renderer ^4.3.2 Write PDFs in React, preview and export share the same codebase
PDF Rendering react-pdf (PDF.js) ^10.4.1 Custom canvas rendering, bypass the browser's built-in viewer
Rich Text Tiptap ^3.20.4 ProseMirror core, outputs structured JSON instead of dirty HTML
i18n next-intl ^4.13.4 Naturally integrates with App Router's [locale] segment
Styling Tailwind CSS ^4 Atomic, mental model matches PDF's StyleSheet approach
Security sanitize-html ^2.17.6 Rich text XSS defense
E2E Playwright ^1.62.1 Core path regression testing

Backend (backend/package.json):

Domain Choice Why
Framework Koa 2.16 Onion model is extremely friendly for unified logging / error handling / response formatting
ORM Sequelize 6.37 Parameterized queries inherently prevent injection
Database MySQL 8 (mysql2)
Authentication koa-session 7 We deliberately chose NOT to use JWT — see section 4.2
Validation Joi 17 Declarative schemas, unified boundaries
Password bcryptjs 3
Captcha svg-captcha No native canvas dependency, deployment-friendly

A counterintuitive choice: We abandoned JWT and went back to Session for authentication. The reason is simple—resumes contain private data, and we need the ability to "log out all devices immediately when the password changes." JWT's statelessness, usually a virtue, is a fatal flaw in this scenario (once issued, a token cannot be revoked). See section 4.2 for implementation details.


II. Core Challenge #1: How to Achieve Flicker-Free, Lag-Free, WYSIWYG PDF Real-Time Preview

This is the most technically sophisticated part of the entire project and deserves the most detailed explanation.

2.1 First, Clarify the Problem

An online resume editor faces an unavoidable contradiction:

  • WYSIWYG requires the preview to be the exact final PDF, not just "an HTML-simulated A4 sheet";
  • But actually generating a PDF is an expensive operation—a two-page resume takes 200–400ms;
  • Users type continuously. If every keystroke triggers a regeneration, the UI will shake like crazy.

Many similar products use an "HTML preview + a separate PDF generation path for export" approach. We tried that path. The conclusion: two codebases will inevitably diverge. Font metrics, line-height calculations, pagination rules—as long as one thing differs, the downloaded PDF won't match what the user saw. This is a fatal flaw for a resume product.

So our approach is: preview and export share the exact same Document code, then use engineering techniques to solve the performance and flicker issues.

2.2 A Single Document Entry Point

frontend/components/pdf/index.tsx:

// PDF Resume Document — preview and download share the exact same codebase
const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

/** Preview and PDF download share this component, guaranteeing WYSIWYG */
export function ResumeDocument({
  data,
  templateId,
  themeColor = DEFAULT_THEME_COLOR,
  pdfBodyPx = DEFAULT_PDF_BODY_PX,
  locale,
  labels,
}: ResumeDocumentProps) {
  ensurePdfFontsForRender(locale);          // Register font subsets per language on demand
  const pdfFontFamily = resolvePdfFontFamily(locale);
  const resolvedId = resolvePdfTemplateId(templateId);
  const Template =
    pdfTemplateComponentById[resolvedId] ??
    pdfTemplateComponentById[defaultPdfTemplateId] ??
    ATemplate;

  return (
    <Document>
      <Template
        data={data}
        themeColor={themeColor}
        pdfBodyPx={pdfBodyPx}
        pdfFontFamily={pdfFontFamily}
        labels={labels}
      />
    </Document>
  );
}

The preview page, download button, thumbnail generation, and public sharing page—all four entry points call this single component. This architecturally eliminates the possibility of "preview diverging from export."

2.3 Why Not Use <PDFViewer>

@react-pdf/renderer officially provides <PDFViewer>, which essentially embeds a blob URL into an <iframe> and delegates to the browser's built-in PDF viewer.

Problems found in production:

  1. Chrome's built-in viewer forcibly adds a toolbar and auto-scales/crops, making it visually messy;
  2. Each blob change causes a full iframe reload—jarring white flash;
  3. Mobile Safari behavior is completely inconsistent and practically unusable.

So we switched to: usePDF gets the blob URL → pass it to react-pdf (PDF.js) for custom canvas rendering.

frontend/components/pdf/ResumePdfJsPreview.tsx:

import { usePDF } from '@react-pdf/renderer';
import { Document as PdfJsDocument, Page, pdfjs } from 'react-pdf';

function ensurePdfWorker() {
  if (workerConfigured || typeof window === 'undefined') return;
  pdfjs.GlobalWorkerOptions.workerSrc = publicAssetUrl('/pdf.worker.min.mjs');
  workerConfigured = true;
}

export function ResumePdfJsPreview({ document: pdfDocument, surfaceColor = '#f0f3fd' }) {
  ensurePdfWorker();

  const [instance, updateInstance] = usePDF();
  useEffect(() => {
    updateInstance(pdfDocument as Parameters<typeof updateInstance>[0]);
  }, [pdfDocument, updateInstance]);
  // ...
}

Deployment detail: PDF.js's worker file must be independently accessible. We added node scripts/copy-pdf-worker.js in the postinstall hook to copy the worker from node_modules to public/, avoiding all the arcane issues bundlers cause with worker files.

2.4 The Core Technique: Double Buffering + Commit Only After All Pages Render

This is the key to eliminating flicker, inspired by double buffering in computer graphics.

Naive approach: New blob arrives → directly replace the file prop → PDF.js clears the canvas → re-parses → renders page by page. The interval between "clear" and "render complete" is the white-flash gap.

Our approach:

  1. When a new PDF arrives, don't touch the currently displayed layer;
  2. Secretly stack a new layer underneath (opacity: 0) and start rendering on it;
  3. Listen for onRenderSuccess on each page, counting with a Set;
  4. Only when all pages are done do we fade the new layer in and the old layer out;
  5. After a 260ms fade-out animation, reclaim the old layer's memory.
const commitFrame = useCallback((url: string) => {
  if (currentUrlRef.current !== url) return;      // Discard stale results
  const previous = visibleUrlRef.current;
  if (previous === url) return;

  fadeTimersRef.current.forEach((timer) => window.clearTimeout(timer));
  fadeTimersRef.current = [];

  visibleUrlRef.current = url;
  setVisibleUrl(url);

  if (previous) {
    fadingUrlRef.current = previous;
    setFadingUrl(previous);
    setFrames((prev) => prev.filter((f) => f.url === url || f.url === previous));

    // Reclaim old frame after 260ms fade to prevent memory leaks
    const clearOldTimer = window.setTimeout(() => {
      setFrames((prev) => prev.filter((f) => f.url !== previous));
      setFadingUrl((current) =>
        current !== previous ? current : ((fadingUrlRef.current = null), null)
      );
      renderedPagesByUrlRef.current.delete(previous);
    }, FRAME_FADE_MS);
    fadeTimersRef.current = [clearOldTimer];
  }
}, []);

// Count pages one by one; commit only when all pages are done
const handlePageRenderSuccess = useCallback(
  (url: string, pageNumber: number, pages: number) => {
    if (currentUrlRef.current !== url || pages <= 0) return;
    let renderedPages = renderedPagesByUrlRef.current.get(url);
    if (!renderedPages) {
      renderedPages = new Set();
      renderedPagesByUrlRef.current.set(url, renderedPages);
    }
    renderedPages.add(pageNumber);
    if (renderedPages.size >= pages) commitFrame(url);
  },
  [commitFrame]
);

The layer component itself is wrapped in memo, with text and annotation layers disabled (the preview doesn't need text selection; disabling them saves 30%+ render time):

const PdfFrameLayer = memo(function PdfFrameLayer({
  frame, pageWidth, visible, fading, onPageRenderSuccess,
}) {
  return (
    <div
      style={{
        opacity: visible ? 1 : 0,
        pointerEvents: 'none',
        transition: fading ? `opacity ${FRAME_FADE_MS}ms ease` : undefined,
        zIndex: fading ? 3 : visible ? 2 : 0,
      }}
      aria-hidden={!visible}
    >
      <PdfJsDocument file={frame.url} loading={null} error={null}>
        {Array.from({ length: frame.pages }, (_, i) => (
          <Page
            key={i}
            pageNumber={i + 1}
            width={pageWidth}
            renderTextLayer={false}         // Preview doesn't need text selection
            renderAnnotationLayer={false}   // Or annotations
            onRenderSuccess={() => onPageRenderSuccess(frame.url, i + 1, frame.pages)}
          />
        ))}
      </PdfJsDocument>
    </div>
  );
});

Key insight: currentUrlRef as a "token check" is critically important. When the user types rapidly, multiple concurrent rendering tasks are spawned. Without validation, a stale task's callback could overwrite a newer result, causing the preview to revert. This is a classic race condition that must be explicitly handled in async rendering scenarios.

2.5 Debounce Strategy: Regenerate Only After Input Pauses

Double buffering solves "flicker" but doesn't solve "computational waste." We also need throttling at the data source:

// Trigger PDF rebuild only after 400ms of input pause;
// But template/theme/font-size changes are "discrete operations" — respond immediately
const debouncedData = useDebouncedValue(resumeData, 400);

const pdfDocument = useMemo(
  () => (
    <ResumeDocument
      data={debouncedData}
      templateId={templateId}      // NOT debounced
      themeColor={themeColor}      // NOT debounced
      pdfBodyPx={pdfBodyPx}
      locale={locale}
      labels={labels}
    />
  ),
  [debouncedData, templateId, themeColor, pdfBodyPx, locale, labels]
);

Design philosophy: Distinguish between "continuous input" and "discrete operations." Typing is continuous and benefits from debouncing; clicking to change a template is discrete with clear user intent and must respond immediately—otherwise it feels unresponsive.


III. Core Challenge #2: Chinese Typesetting and Font Size

3.1 Patching @react-pdf/textkit to Fix CJK Line Breaking

@react-pdf/renderer's layout engine textkit is designed around Western word-breaking rules: space-delimited break points. Chinese has no spaces, so an entire paragraph of Chinese is treated as "one gigantic word." The result:

  • Either the entire paragraph overflows the page margin;
  • Or it brutally truncates in the middle of an English word.

The community issue has been open for a long time without a fix. Our solution: use patch-package to directly patch it:

{
  "scripts": {
    "postinstall": "patch-package && node scripts/copy-pdf-worker.js"
  }
}

The patch's core idea is to inject break points for CJK character ranges in the break opportunity calculation:

// patches/@react-pdf+textkit+x.x.x.patch — core logic
// CJK Unified Ideographs + Full-width punctuation: every character can break
const CJK_RANGE = /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u3000-\u303F]/;

// Also handle "avoid-head" and "avoid-tail" rules:
// Line-start forbidden: ,。、;:!?)》」』】…
// Line-end forbidden: (《「『【

Experience share: When you encounter a third-party library bug, don't blindly fork the entire library to maintain it. patch-package is the optimal solution—patches are committed to the repo as diffs, and upgrades clearly report conflicts to alert you to review. This is an engineering technique every frontend team should master.

3.2 Font Subsetting: From 10MB to 300KB

Chinese fonts are the biggest size killer in PDF solutions. Noto Sans SC with full CJK coverage is 16MB+. Registering it directly means users wait several seconds for the first PDF generation.

Our three-layer optimization:

Layer 1: On-demand registration by language. Users writing an English resume with an English UI don't need Chinese fonts at all:

export function ensurePdfFontsForRender(locale: Locale) {
  if (registeredLocales.has(locale)) return;

  if (locale === 'zh-CN' || locale === 'zh-TW') {
    Font.register({
      family: 'NotoSansSC',
      fonts: [
        { src: fontUrl('NotoSansSC-Regular.subset.ttf'), fontWeight: 400 },
        { src: fontUrl('NotoSansSC-Bold.subset.ttf'), fontWeight: 700 },
      ],
    });
  } else if (locale === 'ja') {
    Font.register({ family: 'NotoSansJP', fonts: [...] });
  } else {
    Font.register({ family: 'Inter', fonts: [...] });   // Western-only, tiny
  }

  registeredLocales.add(locale);
}

Layer 2: Character set trimming. Resume text uses a highly concentrated character set. Use fonttools to subset by GB2312 common characters + common Emoji + Latin alphanumerics and punctuation:

pyftsubset NotoSansSC-Regular.otf \
  --unicodes-file=gb2312.txt \
  --output-file=NotoSansSC-Regular.subset.ttf \
  --flavor=woff2 --layout-features='*'

Result: 16MB → ~300KB, 98% compression.

Layer 3: Host on OSS + CDN with one-year strong caching. Fonts are classic immutable resources—the filename includes a hash, with Cache-Control: max-age=31536000, immutable.

Additional note: Why not use system fonts? Because PDFs must display identically on any device. When HR previews on Mac, opens with Windows Adobe Reader, or views on mobile via WeChat, the font must be embedded. This is a hard requirement for "formal documents" like resumes.


IV. Core Challenge #3: How to Abstract the Template System Without It Spiraling Out of Control

7 templates × 13 modules × 4 languages × theme colors × font sizes—the Cartesian product is a disaster.

4.1 Three-Layer Abstraction

┌─────────────────────────────────────────┐
│  Layer 3: Template Registry              │
│  Declarative config: id / columns / ATS / tier │
├─────────────────────────────────────────┤
│  Layer 2: Template Components (A~G)      │
│  Only responsible for "layout": single-column? two-column? sidebar left or right? │
├─────────────────────────────────────────┤
│  Layer 1: Atomic Modules (13 Sections)   │
│  Basic Info / Education / Work / Projects / Skills... │
│  Styles all injected via props; modules have no opinion │
└─────────────────────────────────────────┘

4.2 Registry-Driven Design

export const PDF_TEMPLATES: PdfTemplateMeta[] = [
  {
    id: 'A',
    slug: 'classic-simple-resume-template',
    columns: 1,
    atsFriendly: true,      // Single-column plain text, ATS-friendly
    tier: 'free',
  },
  {
    id: 'B',
    slug: 'two-column-professional-resume-template',
    columns: 2,
    atsFriendly: false,     // Two-column, some ATS systems read it incorrectly
    tier: 'pro',
  },
  // ...
];

const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

Cost of adding a new template: Write a layout component + add one entry to the registry + one thumbnail. No changes to any module code, no changes to preview or export logic.

4.3 Theme Variables: Font-Size-Linked Typesetting Ratios

When users adjust the "body font size" (12–20px), if only the body text changes and headings stay fixed, the entire visual hierarchy collapses. So we need proportional linkage:

export function buildTypography(pdfBodyPx: number) {
  const base = clamp(pdfBodyPx, 12, 20);
  return {
    body: base,
    small: round(base * 0.86),
    sectionTitle: round(base * 1.18),
    name: round(base * 2.1),
    lineHeight: base <= 13 ? 1.55 : base >= 18 ? 1.38 : 1.46,  // Larger fonts use smaller line-height ratios
    sectionGap: round(base * 1.2),
    itemGap: round(base * 0.7),
  };
}

Typography trivia: Line-height ratios should vary inversely with font size. Smaller fonts need larger ratios (1.55) for readability; larger fonts should be tightened (1.38) or they look loose. This is typography common sense, but many frontend devs hardcode line-height: 1.5.


V. Backend: The Right Way to Use Koa's Onion Model

5.1 Middleware Assembly Order IS Architecture

backend/app.jsthe order is deliberate, each layer's position has a reason:

const Koa = require("koa");
require("./config/env");
const app = new Koa();

// Only trust forwarded IP headers when the backend is confirmed to be
// accessible only via trusted reverse proxy (prevents IP spoofing to bypass rate limiting)
app.proxy = process.env.TRUST_PROXY === "1";

// Validate secret key strength at startup—don't start with bad config,
// don't wait until you're attacked to find out
const sessionSecret = String(process.env.SESSION_SECRET || "");
if (sessionSecret.length < 32) {
  throw new Error("SESSION_SECRET must contain at least 32 characters");
}
for (const name of ["VERIFICATION_CODE_SECRET", "LOG_HASH_SECRET", "WECHAT_WEB_STATE_SECRET"]) {
  const value = String(process.env[name] || "");
  if (value && value.length < 32) {
    throw new Error(`${name} must contain at least 32 characters when configured`);
  }
}

app.use(requestContext());   // ① Request ID + structured logging (outermost to capture full request lifetime)
app.use(errorHandler());     // ② Unified error capture (as close to the outer edge as possible to catch all throws)

app.use(async (ctx, next) => {   // ③ Security response headers
  ctx.set("X-Content-Type-Options", "nosniff");
  ctx.set("X-Frame-Options", "DENY");
  ctx.set("Referrer-Policy", "no-referrer");
  ctx.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  if (process.env.NODE_ENV === "production" && ctx.secure) {
    ctx.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
  }
  // Private endpoints must never be cached
  if (ctx.path.startsWith("/private/") || ctx.path.startsWith("/public/user/")) {
    ctx.set("Cache-Control", "no-store");
  }
  await next();
});

app.use(globalLimiter);      // ④ Global IP rate limiting: 120 req/min
app.use(serve(path.join(__dirname, "public"), { maxage: 365 * 24 * 60 * 60 * 1000 }));

app.use(cors({               // ⑤ Strict CORS whitelist
  origin: (ctx) => {
    const origin = ctx.get("origin");
    if (!origin) return "";
    if (corsOrigins.length === 0) {
      return process.env.NODE_ENV === "production" ? "" : origin;  // Production must not run naked
    }
    return corsOrigins.includes(origin) ? origin : "";
  },
  credentials: true,
}));

onerror(app);

app.use(bodyparser({         // ⑥ Differentiated body size limits
  enableTypes: ["json", "form", "text"],
  jsonLimit: `${maxResumeContentBytes + 64 * 1024}b`,   // Resume JSON needs a larger quota
  formLimit: "64kb",                                    // Tighten form limit
  textLimit: "64kb",
}));
app.use(json());
app.use(csrfProtection());   // ⑦ CSRF (Origin / Referer / Sec-Fetch-Site triple validation)

app.keys = [sessionSecret];
app.use(session({
  key: "beautyresume.sid",
  httpOnly: true,
  signed: true,
  sameSite: "lax",
  secure: process.env.SESSION_COOKIE_SECURE === "1" || process.env.NODE_ENV === "production",
  genid: () => crypto.randomUUID(),
  maxAge: 7 * 24 * 60 * 60 * 1000,
  renew: true,
  overwrite: true,
}, app));

app.use(async (ctx, next) => {  // ⑧ Path-prefix authentication gateway
  if (ctx.path.startsWith("/private")) {
    await requireLogin(ctx, next);
    return;
  }
  await next();
});

app.use(index.routes(), index.allowedMethods());  // ⑨ Business routes

// ⑩ Force API responses to JSON — mounted AFTER routes
app.use(require("./middleware/jsonContentType")());

Two designs worth highlighting:

① Why is jsonContentType mounted after the routes?

This is the only middleware in the entire stack that does "work after await next()" — it leverages the outbound phase of the onion model:

// middleware/jsonContentType.js
module.exports = () => async (ctx, next) => {
  await next();                       // Let routes run first
  if (ctx.path.startsWith('/api') && ctx.type !== 'application/json') {
    ctx.type = 'application/json';    // Uniformly override on the way back
  }
};

In production, we've seen an anomalous path return Koa's default HTML error page, and the frontend's JSON.parse threw an exception, causing a full-page white screen. With this "outbound guard" layer, API paths always return JSON, so the frontend can safely parse.

This is the essence of the onion model—a single middleware can operate at both "request ingress" and "response egress" phases. Many people use Koa but only use half its power.

② Prefix gateway authentication instead of per-route guards

Instead of attaching requireLogin to every route, a single prefix middleware uniformly intercepts /private/*. The benefit: you can never forget to add authentication to a new private endpoint—security is enforced by architecture, not by developer discipline. The route table only needs to annotate finer-grained requireAdmin / requireEntitlement checks.

5.2 Why Session Instead of JWT

Time to fill the hole we dug earlier. The core is the sessionVersion field enabling "force logout on all devices":

// backend/middleware/auth.js
async function loadCurrentUser(ctx) {
  const userId = ctx.session && ctx.session.userId;
  if (!userId) return null;

  const user = await User.findByPk(userId);
  if (!user || user.status !== 'active') {
    ctx.session = null;
    return null;
  }

  // Core: the version number stored in the session MUST match the current version in the DB
  if (Number(ctx.session.sessionVersion) !== Number(user.sessionVersion)) {
    ctx.session = null;     // Version mismatch → this session is invalidated
    return null;
  }

  return user;
}

When a user performs "change password / unbind WeChat / proactively log out all devices," just sessionVersion++:

await user.increment('sessionVersion');
// At this moment, all sessions of this user on all devices will fail on the next request

To achieve the same with JWT, you'd need to maintain a blacklist table and query it on every request—at which point you've essentially degenerated back to Session, plus added token size and signing overhead for no reason.

Selection principle: Statelessness is not a silver bullet. When the business inherently needs state (revocable login sessions), forcing a stateless solution only distorts the architecture.

Resumes contain names, phone numbers, emails, and other strongly private information. Using auto-incrementing IDs like /resume/123 is equivalent to exposing every user's private data on the public internet for anyone to scrape.

Our solution: encrypted random slugs:

const crypto = require('crypto');

// 22-character base64url, ~128 bits of entropy — brute-force enumeration infeasible
function generateResumeSlug() {
  return crypto.randomBytes(16).toString('base64url');
}

Layered with data masking on share:

function maskContact(value, type) {
  if (!value) return '';
  if (type === 'phone') {
    return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
  }
  if (type === 'email') {
    const [name, domain] = value.split('@');
    if (!domain) return value;
    const visible = name.slice(0, Math.min(2, name.length));
    return `${visible}${'*'.repeat(Math.max(1, name.length - 2))}@${domain}`;
  }
  return value;
}

Plus a search engine indexing toggle (default off). When disabled, the share page receives X-Robots-Tag: noindex, nofollow. Three lines of defense: non-enumerable + content masking + not indexed.


VI. SEO Engineering: Sitemap Sharding and hreflang Automation

For a content-driven product, traffic relies on SEO. We have: core pages + 7 template detail pages + hundreds of career/interview articles, × 4 languages—the URL count easily exceeds a thousand.

6.1 Sharded Index

A single sitemap.xml can hold up to 50,000 URLs or 50MB—while we're nowhere near that, the real value of sharding lies in "incremental updates"—update an article and you only regenerate sitemap-articles.xml; template pages remain untouched. Search engine crawl efficiency improves.

<!-- public/sitemap.xml -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://beautyresume.com/sitemap-core.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-templates.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-articles.xml</loc></sitemap>
</sitemapindex>

6.2 Auto-Generated Full-Cross hreflang

The most error-prone part of multilingual SEO: hreflang must be bidirectionally cross-referenced—every language version's page must list all language versions (including itself), plus x-default. Manual maintenance is a guaranteed source of errors.

// frontend/scripts/generate-sitemap.js
const SITE_ORIGIN = (process.env.SITE_ORIGIN || 'https://beautyresume.com').replace(/\/$/, '');
const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja'];
const DEFAULT_LOCALE = 'zh-CN';

function buildAlternates(pathWithoutLocale) {
  const links = LOCALES.map(
    (l) => `<xhtml:link rel="alternate" hreflang="${l}" href="${SITE_ORIGIN}/${l}${pathWithoutLocale}" />`
  );
  links.push(
    `<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_ORIGIN}/${DEFAULT_LOCALE}${pathWithoutLocale}" />`
  );
  return links.join('\n');
}

function buildUrlEntries(pathWithoutLocale, { changefreq = 'weekly', priority = 0.8 } = {}) {
  const alternates = buildAlternates(pathWithoutLocale);
  // Each language generates one <url>, all carrying the full alternates
  return LOCALES.map((locale) => `<url>
<loc>${SITE_ORIGIN}/${locale}${pathWithoutLocale}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
${alternates}
</url>`).join('\n');
}

A companion validate-sitemap.js script checks URL reachability, hreflang symmetry, and duplicate locs, and is integrated into CI:

{
  "scripts": {
    "seo:validate": "yarn sitemap:generate && yarn sitemap:validate",
    "build": "node scripts/generate-sitemap.js && next build"
  }
}

Baking sitemap generation into the build process means it's never stale—this is infinitely more reliable than a "remember to manually update the sitemap" manual workflow.

6.3 Next.js Metadata and Canonical

// frontend/app/[locale]/layout.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale } = await params;
  return {
    metadataBase: new URL(SITE_ORIGIN),   // Auto-converts all relative URLs to absolute
    alternates: {
      canonical: localePath(locale, pathWithoutLocale),
      languages: buildLanguageAlternates(pathWithoutLocale),
    },
    openGraph: { /* ... */ },
  };
}

VII. Deployment: Next.js standalone Shrinks Image by 80%

7.1 Standalone Output

One line of config in next.config.js:

module.exports = {
  output: 'standalone',
};

Next.js performs static dependency tree analysis, copying only the files actually referenced from node_modules into .next/standalone. Comparison:

Approach Size
Full node_modules + .next ~1.2 GB
standalone output ~180 MB

What scripts/standalone-pack.js does:

.next/standalone/          ← Main body (with lean node_modules and server.js)
  + .next/static/          ← MUST be manually copied! Next doesn't auto-include it
  + public/                ← Same, MUST be manually copied
  → archiver zip

Pitfall warning: .next/static and public are NOT automatically included in the standalone directory. This is a passing remark in Next.js official docs, but missing it means "pages load but CSS/JS all 404." Almost everyone hits this on their first deployment.

7.2 PM2 Cluster + Graceful Shutdown

pm2 start bin/www -i max --name beautyresume-api

backend/bin/www handles graceful shutdown to prevent in-flight requests from being forcibly cut off during deploys:

const server = app.listen(port);

function shutdown(signal) {
  console.log(`[${signal}] shutting down gracefully...`);
  server.close(async () => {
    await sequelize.close();     // Close connection pool
    process.exit(0);
  });
  // Fallback: force kill after 15 seconds to prevent zombie processes occupying the port
  setTimeout(() => process.exit(1), 15000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

7.3 Key Nginx Configuration

# Static assets: hashed filenames, one-year strong cache
location /_next/static/ {
    proxy_pass http://127.0.0.1:3001;
    proxy_cache_valid 200 365d;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# API reverse proxy to Koa
location /api/ {
    proxy_pass http://127.0.0.1:7001;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# SSR pages
location / {
    proxy_pass http://127.0.0.1:3001;
    proxy_set_header Host $host;
}

gzip on;
gzip_types text/plain text/css application/json application/javascript
           application/xml image/svg+xml font/ttf font/woff2;
gzip_min_length 1024;

Paired reminder: Only when Nginx correctly sets X-Forwarded-For should the backend enable TRUST_PROXY=1. Otherwise, attackers can forge this header to bypass IP rate limiting—these two configurations must appear as a pair, neither can be missing.


VIII. Pitfalls and Lessons Learned

8.1 React 19 PDF Rendering Quirks

@react-pdf/renderer has its own internal reconciler. In React 19's strict mode, usePDF's updateInstance may be called twice, producing two blob URLs. The solution is the currentUrlRef token check mentioned earlier, plus URL.revokeObjectURL() in useEffect cleanup—otherwise, ten minutes of continuous editing can eat several hundred MB of memory.

8.2 Don't Use Tiptap StarterKit

// ❌ Everything bundled, double the size
"@tiptap/starter-kit": "^3.20.4"

// ✅ Import on demand—resumes only need bold and lists
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-list": "^3.20.4"

StarterKit bundles 20+ extensions (headings, code blocks, blockquotes, images, tables...), none of which a resume editor needs. The editor chunk shrunk by ~40% after on-demand imports.

More importantly: fewer features = users can't create weird layouts. Constraint IS product design.

8.3 Redux Fine-Grained Subscriptions

Resume data is a deeply nested large object. If every form component uses useSelector(state => state.resume), any field change triggers a full-form re-render.

// ❌ Any field change triggers full re-render of all subscribed components
const resume = useSelector((s) => s.resume);

// ✅ Subscribe only to the slice you care about
const workList = useSelector((s) => s.resume.work.list, shallowEqual);

// ✅ Derive data with createSelector for memoization
const selectVisibleSections = createSelector(
  [(s) => s.resume.sections, (s) => s.resume.sectionOrder],
  (sections, order) => order.filter((id) => sections[id]?.visible)
);

8.4 Security Checklist (Hard-Earned Edition)

Item Practice
Password bcrypt, cost ≥ 10, NEVER plaintext/MD5
Session httpOnly + signed + sameSite=lax + secure enforced in production
CSRF Origin / Referer / Sec-Fetch-Site triple validation
Rate Limiting Global IP 120/min; login/captcha endpoints separately tightened
SQL Injection All Sequelize parameterized queries; absolutely no string interpolation
XSS Rich text sanitized via sanitize-html whitelist before storage
Authorization Every resource operation checks resource.userId === ctx.state.user.id
Logging Sensitive fields hashed with LOG_HASH_SECRET before being written to disk
Secrets Validate length ≥ 32 at startup; refuse to start if non-compliant
Enumeration All public resources use encrypted random slugs

The last item is the most easily overlooked: Put "config validation" in the startup phase. Let errors surface at deploy time, not when you're under attack.


IX. Final Thoughts

Looking back at the entire project, the biggest takeaway isn't mastering a particular API, but several transferable engineering judgments:

  1. Consistency over performance. Sharing one codebase for preview and export seems like making trouble for yourself (all the performance pressure lands on preview), but it architecturally eliminates the entire class of "WYSINWYG" bugs. Performance issues can be solved with engineering techniques (double buffering, debouncing); consistency can only be guaranteed by architecture.

  2. Statelessness is not a silver bullet. JWT is trendy, but when the business needs "revocable login sessions," Session + version number is the right answer. Choose based on business essence, not technical hype.

  3. Turn security into architectural constraints, not developer discipline. Prefix authentication gateway, startup secret validation, build-time sitemap generation—let the right things happen automatically; it's far more effective than writing ten pages of development guidelines.

  4. When you find a third-party library bug, patch-package beats forking. Patches live in the repo; upgrades automatically alert you to review. Maintenance cost is near zero.

  5. Constraint is good design. Only enable bold and lists in Tiptap, and users paradoxically can't create ugly resumes.


If this article helped you, please like and bookmark it. Any technical questions are welcome in the comments—I'll reply to each one.

Please credit the source when republishing. All code in this article comes from a real production project and can be referenced with confidence.

#Next.js#React 19#Koa2#PDF实时预览#全栈#简历编辑器#SEO#多语言#架构设计