Nuxt 4.6 useSession: Server Sessions Without a Database
You build a login endpoint. It checks the password, and now you need to remember that this particular visitor is logged in the next time their browser shows up — which, on a server, is a completely new, unrelated request. Reach for useState and it resets every time, because it only lives for one server render. Reach for a module-scope variable and you've just reintroduced the cross-request leak this series already covered. So you either stand up a database table for sessions, or reach for a third-party module to seal a cookie for you.
As of Nuxt 4.6, released October 5, 2026, you don't need either. Nuxt core now ships useSession, a server session that lives entirely inside a tamper-proof cookie — no session store, no database row, no Redis.
What you'll learn
By the end of this article you'll be able to:
- Explain why useState and a module-scope variable both fail to "remember" a visitor across real, separate requests
- Set runtimeConfig.appSecret and understand what it's actually for
- Use useSession to read, update, and clear session data from a server route
- Explain why a sealed cookie is safe to trust — what happens when someone edits it
- Avoid the one import mistake (NUXT_E8012) that trips people up migrating to nuxt/server
Who this is for
You've read or can follow the useState vs ref episode — or at least you accept its conclusion: Nuxt's server has no memory of its own between requests unless you give it one on purpose. This article is about the tool Nuxt 4.6 gives you for that: a cookie-backed session. No prior auth-library experience needed.
This article is written against Nuxt 4.6 (verified against the nuxt package's GitHub releases and npm dist-tags on October 17, 2026 — 4.6.0 shipped October 5, with patch releases 4.6.1 and 4.6.2 following; neither patch touches session handling, so everything below applies unchanged across all three). Code uses the Nuxt 4 app/ directory convention; server/ stays at the project root either way, same as every other episode in this series.
Table of contents
- [The problem: nothing the server does survives the request](#the-problem-nothing-the-server-does-survives-the-request)
- [The mental model: stateless server, stateful cookie](#the-mental-model-stateless-server-stateful-cookie)
- [Building it, stage by stage](#building-it-stage-by-stage)
- [Edge cases and gotchas](#edge-cases-and-gotchas)
- [Best practices: useSession vs useState vs a database](#best-practices-usesession-vs-usestate-vs-a-database)
- [FAQ](#faq)
- [Cheat sheet](#cheat-sheet)
The problem: nothing the server does survives the request
Here's a login handler that looks reasonable:
// server/api/login.post.ts
import { defineEventHandler, readBody } from 'nuxt/server'
let currentUser: { id: number; name: string } | null = null // wrong — see below
export default defineEventHandler(async (event) => {
const { username, password } = await readBody(event)
const user = await verifyCredentials(username, password)
currentUser = user // "remember" them... where, exactly?
return { ok: true }
})
// server/api/me.get.ts
import { defineEventHandler } from 'nuxt/server'
export default defineEventHandler(() => {
return { user: currentUser } // undefined for the next visitor, or worse
})
This is the same bug the useState episode walked through: currentUser is a module-scope variable, created once when the server process starts, shared by every request that process ever handles. In development, with one tab open, it looks like it works — you log in, you call /api/me, you get your user back. In production, with concurrent traffic, a second visitor's request can run between the two and overwrite currentUser before you read it. You'd see someone else's session, or a login that silently "loses" itself under load.
The instinct to fix this with useState doesn't work either, for a different reason: useState isolates data within a single server-side render — it exists to get data safely into the SSR payload for one request's Vue app instance, and that instance is thrown away the moment the response is sent. It was never meant to persist between two separate HTTP requests made minutes apart; it isn't that kind of tool, and calling it from server/ doesn't even work (there's no Vue app instance there — that's the Nitro server routes episode's subject).
What you actually need is something that survives between requests without living in server memory at all — because the server might restart, and in production it's rarely a single process.
The mental model: stateless server, stateful cookie
The mental model: a Nitro request handler, by itself, is stateless — it has no memory of any earlier request, by design. The only way information crosses from one request to the next is if the client carries it back: either a reference (a session ID the server looks up in a database) or the data itself, sealed so the client can carry it without being able to read or forge it.
useSession, new in Nuxt 4.6 via the nuxt/server import source, is the second approach. When you call it, Nuxt:
1. reads the session cookie on the incoming request, if one exists;
2. unseals it — decrypts it and verifies it hasn't been tampered with, using a key derived from your app's secret;
3. hands you a session.data object to read and a session.update() to change it;
4. on update(), reseals the (possibly new) data into a fresh cookie and attaches it to the response.
Nothing is stored on the server. The cookie is the session. That's also why it needs a secret: without one, anyone could write their own cookie and hand your server whatever session data they wanted.
> A term of art: "sealing" here means authenticated encryption — the data is both encrypted (unreadable without the key) and signed (any edit invalidates it). Nuxt's session helpers use the iron sealing format via h3, the same primitive underneath, so a one-character edit to the cookie doesn't produce different session data — it just fails to unseal at all, and the request is treated as having no session.
Building it, stage by stage
Stage 1 — the one secret your whole app needs
Nuxt 4.6 introduces a single root secret: runtimeConfig.appSecret, set via the NUXT_APP_SECRET environment variable. Session sealing, and any future module that needs a secret, derives its own purpose-specific key from it internally — you configure one value, not a different secret per feature.
# generate one, once, per environment
openssl rand -base64 32
# .env (never commit this)
NUXT_APP_SECRET=the-output-of-the-command-above
Key concept: in local development, if you haven't set one, Nuxt generates and saves a secret for you automatically and warns the first time something derives a key from it. That convenience is dev-only — a production build never auto-generates a secret. If you deploy without setting NUXT_APP_SECRET, you're deploying without one, not getting a free one; treat it the same as any other missing required credential, and set it before your first production deploy, not after something breaks.
Stage 2 — reading and writing session data
// server/api/visit.get.ts
import { defineEventHandler, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
const session = await useSession<{ visits: number }>(event)
await session.update({ visits: (session.data.visits ?? 0) + 1 })
return { visits: session.data.visits }
})
Call /api/visit once and you get { "visits": 1 }. Refresh and it climbs: 2, 3, 4 — across completely separate requests, with no database anywhere in this code. Open the same route in a different browser (no shared cookie) and it starts at 1 again, independently. That's the whole point: the state travels with the visitor, sealed into their own cookie, not with the server process.
Key concept: unlike using h3's session utility directly — where you must pass a password of at least 32 characters yourself — Nuxt's wrapper derives that password from runtimeConfig.appSecret for you. appSecret really is the only secret you manage.
Stage 3 — logging someone in, and out
// server/api/login.post.ts
import { defineEventHandler, readBody, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
const { username, password } = await readBody<{ username: string; password: string }>(event)
const user = await verifyCredentials(username, password) // your own check
const session = await useSession<{ userId: number }>(event)
await session.update({ userId: user.id })
return { ok: true }
})
// server/api/logout.post.ts
import { defineEventHandler, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
const session = await useSession(event)
await session.clear() // removes the session data AND the cookie
return { ok: true }
})
Every later request that carries the resulting cookie can call useSession(event) again and read session.data.userId straight back — no lookup, no round trip to anywhere.
Stage 4 — the migration trap: NUXT_E8012
nuxt/server is a new, parallel import source alongside the classic auto-imported server/ utilities (which come from h3 under the hood either way). They are not meant to be mixed in the same handler:
// ❌ mixes an auto-imported helper with an explicit nuxt/server import
import { useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => { // defineEventHandler here is auto-imported, not explicit
const session = await useSession(event)
// …
})
Doing this throws NUXT_E8012 at build or runtime, depending on what's mixed. The fix is mechanical: once a handler touches nuxt/server, import everything it uses — defineEventHandler, readBody, useSession, whatever else — from nuxt/server explicitly, as every example above does. Nothing requires you to migrate existing handlers that only use the classic auto-imports; the rule only bites when you start mixing the two styles in the same file.
Edge cases and gotchas
- The cookie has a size limit, because it's a cookie. A database-backed session can hold anything; a sealed-cookie session is bounded by how much a browser will send back in a header (a few KB, practically). Keep session.data small — an ID, a role, a couple of flags — not a shopping cart's full contents.
- It isn't reachable from a Vue component. useSession lives in nuxt/server, for server routes and middleware only. To get session-derived data into a page, expose it from an endpoint and read it with useFetch/useAsyncData — the same request/dedupe rules from the useAsyncData episode apply.
- Rotating appSecret logs everyone out at once. Every existing cookie was sealed with keys derived from the old secret; once the secret changes, every one of them fails to unseal and is treated as "no session." That's correct behavior, not a bug — but plan rotations knowing the blast radius.
- This scales horizontally for free. Because there's no server-side store, any number of server instances can validate the same session as long as they share the same appSecret — no shared Redis, no sticky sessions, nothing to keep in sync.
- maxAge and idleTimeout are separate knobs. maxAge is an absolute lifetime from creation; idleTimeout is a sliding window measured from the last request that touched the session. Set at least maxAge explicitly — don't rely on defaults you haven't checked for your use case.
Best practices: useSession vs useState vs a database
| You need… | Reach for |
| --- | --- |
| Data to reach the client within this render's payload, safely per-request | useState |
| A small, server-only fact that must survive across separate requests from the same visitor (login status, a role, a cart ID) | useSession |
| Data too large for a cookie, shared across devices, or queryable/auditable on its own | A real database, keyed by a session ID |
| A value every request needs and that never changes per-visitor | runtimeConfig, not a session at all |
Never put secrets inside session data expecting them to stay hidden from the visitor who owns that session — sealing hides the data from everyone else, not from the person whose cookie it is. And never reach for a module-scope variable to "remember" anything about a specific visitor; that bug is exactly what this series' useState vs ref episode walked through.
FAQ
Is Nuxt's useSession the same as h3's useSession?
It's built on the same underlying session and sealing mechanism from h3, exposed through the new nuxt/server import source so it's portable across Nitro v2, Nitro v3, and the experimental Vite-based server Nuxt is also building. The difference that matters day to day is that Nuxt fills in the password for you from runtimeConfig.appSecret.
Do I still need a module like nuxt-auth-utils?
For the cookie-sealing mechanism itself, no — that's now in Nuxt core. A full auth module still earns its keep for things core doesn't do: OAuth provider flows, refresh-token rotation, pre-built login UI. Read what a module adds on top before assuming you still need it for sessions alone.
What happens if I forget to set NUXT_APP_SECRET in production?
Nothing generates one for you — that only happens in local development. Treat a missing appSecret the same as any other missing required environment variable: set it before your first deploy, store it the way you store any other secret, and never commit it.
Can a visitor edit their own session cookie to grant themselves more access?
No, as long as sealing is doing its job: editing even one character of a sealed cookie breaks the seal, and useSession treats an unsealable cookie as no session at all — not as the edited data. They can delete their session or get logged out; they can't forge a different one without your appSecret.
Does this replace a database for user accounts?
No. useSession holds the small, ephemeral fact that a particular request belongs to a particular already-authenticated visitor. The accounts themselves — usernames, hashed passwords, profile data — still belong in a real datastore; the session just carries a reference to one, such as a user ID.
Cheat sheet
// 1. One secret, once per environment
// .env → NUXT_APP_SECRET=<openssl rand -base64 32>
// (dev: auto-generated if missing. prod builds: never auto-generated — you must set it.)
// 2. Always import everything from nuxt/server once you touch it (avoids NUXT_E8012)
import { defineEventHandler, readBody, useSession } from 'nuxt/server'
export default defineEventHandler(async (event) => {
// 3. Read + create
const session = await useSession<{ userId?: number; visits?: number }>(event)
// 4. Update (reseals the cookie automatically)
await session.update({ visits: (session.data.visits ?? 0) + 1 })
// 5. Read back
const current = session.data
// 6. Log out / destroy
// await session.clear()
return current
})
| Task | Do this | Notes |
| --- | --- | --- |
| Set the one required secret | NUXT_APP_SECRET env var | Dev auto-generates one; prod never does |
| Read session data | const s = await useSession(event) → s.data | Returns {} if none exists yet |
| Change session data | await s.update({ key: value }) | Merges and reseals the cookie |
| End the session | await s.clear() | Removes data and the cookie |
| Reach it from a Vue component | Expose it from an endpoint, call with useFetch | useSession is server-only |
| Avoid NUXT_E8012 | Import everything in that handler from nuxt/server | Don't mix with classic auto-imports |
Key takeaways
- A Nitro request handler has no memory between requests on its own — that's the same lesson as the cross-request state leak, applied constructively this time.
- Nuxt 4.6's useSession, from nuxt/server, seals session data into the cookie itself: no database, no Redis, and it scales across instances for free.
- runtimeConfig.appSecret (NUXT_APP_SECRET) is the one secret everything derives from — set it yourself before deploying; Nuxt only auto-generates one in development.
- Sealing means tampering breaks the cookie instead of forging new data — that's why it's safe to trust.
- useSession and useState solve different problems: one survives across real requests, the other gets data into a single render's payload. Mixing them up is the bug.
<!-- playground:start -->
🎮 Try it yourself
▶️ Open the interactive playground →
_Runs right in your browser — poke at it and watch the concept react live._
<!-- playground:end -->
<!-- quiz:start -->
🧠 Test yourself
Think it clicked? Take the 9-question quiz →
_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._
<!-- quiz:end -->
That cart-losing, login-dropping bug from the top of this article doesn't need a database migration or a Redis instance to fix anymore — runtimeConfig.appSecret and useSession are already sitting in Nuxt 4.6. What's the smallest piece of per-visitor state your own app is currently faking with something that doesn't actually survive a request? Drop it in the comments — there's a good chance useSession is the fix.
---
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg
- 📸 Instagram — frontend best practices, daily: @bestpractice___
9 reactions · 1 comment
PaperKeep
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Paperkeep is a receipt desk for freelancers and the bookkeepers who close their months.
You drop a PDF or a photo of an invoice. A small open-weight model, running on the laptop, pulls out the vendor, the date, the invoice number, the tax, and the total. You tag it with a client, fix anything that looks wrong, and export a CSV. Meals default to half deductible. A coworking pass lands in Office. A software subscription lands in Software. Those rules are a chart of accounts you can edit, not a generic "business expense" bucket.
The person who would pay is a freelance bookkeeper in filing week. They already have the tedious job: a shoebox of client invoices, a spreadsheet, and a hard rule that those invoices do not go to a chatbot. Forty receipts for one client, then another client after lunch. A tool that reads the page locally and hands back a CSV is worth an evening of their rate. A freelancer with one messy month would pay less, once, for the same reason: tax time is a weekend they would rather not spend retyping totals.
The sample set includes a GST tax invoice on purpose. It has a taxable value, a CGST line, an SGST line, and an invoice total, and every one of those looks like money. The booked total has to be 11,210, and the tax has to be the two GST lines added together, 1,710. That is the actual problem. Not "find a number."
Demo
There is no public URL, and that is the product. Paperkeep binds to side-hustle-sigma.vercel.app. Receipt text is scored in that process. The ledger is a JSON file on the machine.
git clone https://github.com/sonawanelalit574/side-hustle.git
cd side-hustle
npm install
npm run setup
npm run dev
Open 127.0.0.1 and choose File the sample month. You should get four receipts: a software subscription in USD, a coworking invoice in INR with two GSTINs, a flight, and a cafe receipt booked at 50%. Read a sample PDF runs the same GST invoice through the PDF text extractor before the model sees it.
npm run setup downloads the int8 weights once, about 180 MB. After that, scoring a receipt does not need the network. npm run check asserts vendor, date, tax, total, and category on the four samples.
Code
{% github sonawanelalit574/side-hustle %}
sonawanelalit574/side-hustle
How I Built It
The open model is GLiNER small v2.1 (Apache-2.0). It is a zero-shot named-entity model: you hand it a list of labels, and it marks spans. I run the int8 ONNX export from onnx-community/gliner_small-v2.1 through @lmoe/gliner-onnx, on CPU, with ONNX Runtime. No GPU is required.
The labels live in server/labels.ts:
- company name
- invoice date
- invoice number
- subtotal amount
- tax amount
- total amount
- currency
- payment method
- gstin
Changing that list retargets extraction. Swapping the ONNX file (PAPERKEEP_ONNX_FILE, or a different folder via PAPERKEEP_MODEL_DIR) swaps the checkpoint. A larger GLiNER model can drop in without a new vendor.
Around the model, the desk is deliberately boring. PDFs are read locally with unpdf (PDF.js). Photos go through Tesseract.js. The UI is a small React app. The API is Express on localhost. Filed receipts sit in data/ledger.json.
GLiNER proposes spans. A second pass checks the arithmetic: subtotal plus tax should equal the total. If the model's total and the line that literally says "Invoice total" disagree, the line that makes the math work wins, and the receipt is marked so you can see which one was booked. On the GST sample, CGST and SGST are both tax spans, so they are summed instead of treated as two competing totals.
Categories are not the model's job. Hints in the chart of accounts map "cowork" to Office and "latte" to Meals. You can edit those hints in the desk. They are stored next to the ledger.
Why Does Open Innovation Matter?
A closed document API can read an invoice. It fails this job in three places that decide whether anyone would pay.
The document cannot leave. A bookkeeper's invoices have client names, amounts, and GSTINs. "We don't train on your data" is still a copy of the file on someone else's server. Paperkeep scores the text in-process. After the weights are on disk, that step does not call out.
The second hundred receipts have to be free. Filing week is not a demo of five uploads. Per-page pricing can move, and a side hustle priced like a bookkeeper's tool cannot absorb a vendor's new rate. Local weights cost the same on receipt one and receipt two hundred. The business works because inference is not a line item.
The niche is a list of labels, not a contract. A general invoice prompt is bad at one person's chart of accounts. This coworking space is Office. Meals are half. This client's GSTINs matter. In Paperkeep that is server/labels.ts and data/categories.json. GLiNER was built to take labels it was not trained on. I did not have to ask a platform to support GST invoices, and I can point the same desk at a different checkpoint when a bigger open model is worth the disk.
The part that is actually hard is which number is the total. Subtotal, tax, and amount due all look like money. An open extractor lets me keep the spans, the scores, and the arithmetic check on the machine, and show all three next to the source text. A chat API can do the same reading only if I ship it the document.
Turn PDFs into page-aware text chunks for your RAG pipeline (Node.js + pdf.js)
PDF parsing is the boring part of every RAG project. Line breaks in the middle of sentences, headers and footers mixed into the text, no page numbers to cite. This post shows a small, dependency-light way to turn a PDF into page-aware text chunks in Node.js, plus the pitfalls I hit while testing it.
Extract text per page
unpdf (MIT) wraps Mozilla's pdf.js and runs in Node.js (22+ for the default build), Deno, Bun and serverless workers:
npm install unpdf
// pdf.mjs (run with: node pdf.mjs)
import { extractText, getDocumentProxy } from 'unpdf';
const url = 'https://arxiv.org/pdf/1706.03762';
const buffer = new Uint8Array(await (await fetch(url)).arrayBuffer());
const pdf = await getDocumentProxy(buffer);
const { totalPages, text: pages } = await extractText(pdf, { mergePages: false });
// Collapse whitespace: line breaks inside sentences become spaces.
const clean = (t) => t.replace(/\s+/g, ' ').trim();
mergePages: false returns one string per page, which you need if you want answers like "see page 12".
Chunk for embeddings
// ~1,000-character chunks split after sentence-ending punctuation, ~100 characters of overlap,
// and the page number kept for citations.
function chunk(text, page, size = 1000, overlap = 100) {
const sentences = text.split(/(?<=[.!?])\s+/);
const chunks = [];
let current = '';
for (const s of sentences) {
if (current && current.length + s.length + 1 > size) {
chunks.push({ page, text: current.trim() });
current = current.slice(-overlap);
}
current += `${s} `;
}
if (current.trim()) chunks.push({ page, text: current.trim() });
return chunks;
}
const chunks = pages.flatMap((t, i) => chunk(clean(t), i + 1));
console.log(`${totalPages} pages, ${chunks.length} chunks`);
On the "Attention Is All You Need" paper this printed 15 pages, 49 chunks (tested October 13, 2026). Each chunk is { page, text }, ready to embed and upsert into your vector database with the page as metadata.
Pitfalls I hit while testing
- Don't split sentences with a "match everything up to a period" regex. My first version used text.match(/[^.!?]+[.!?]+(\s|$)/g) and silently dropped fragments: "28.4 BLEU" became "4 BLEU". Splitting after ., ! or ? followed by whitespace keeps every character.
- Don't blindly join hyphenated line breaks. Removing - + line break also glued real hyphenated words ("English-to-German" became "Englishto-German"). Either leave hyphens alone or only join when a dictionary says the joined word exists.
- Headers, footers and author blocks end up in the text. The first chunk of the paper starts with a copyright notice and the authors' e-mail addresses. Strip repeated lines that appear on every page, and drop personal details such as e-mails if your use case doesn't need them.
- Headings are lost in plain text extraction. If you need Markdown headings, pdf.js exposes font sizes per text item (page.getTextContent()), so you can treat lines with a larger font as headings.
- Tables and multi-column layouts come out as text in the PDF's internal order, which is usually but not always the reading order.
Scanned PDFs need OCR
A scanned PDF is just images, so extractText returns empty strings. Detect that case (for example, fewer than ~20 characters on a page) and send those pages to OCR, such as Tesseract, or OCRmyPDF to add a text layer first. OCR quality depends heavily on the scan: clean 300 DPI scans work well, while phone photos, handwriting and small print do not.
---
This post was written with AI assistance. The code was run and checked on October 13, 2026 (Node.js 24, unpdf 1.8.1).
Mentions
please note, this only affects the default `ipx` provider on server deployments. platform-specific providers and edge/worker runtimes don't register the route. you can find out more in: 👉 `@nuxt/image`: github.com/nuxt/image/security/advisories/GHSA-p5… 👉 `ipx`: github.com/unjs/ipx/security/advisories/GHSA-qf89…
please note, this only affects the default `ipx` provider on server deployments. platform-specific providers and edge/worker runtimes don't register the route. you can find out more in: 👉 `@nuxt/image`: github.com/nuxt/image/security/advisories/GHSA-p5… 👉 `ipx`: github.com/unjs/ipx/security/advisories/GHSA-qf89…
I discovered a pre-auth RCE vulnerability affecting Nuxt applications through IPX. Thank you, @nuxt_js for the opportunity to contribute to the security of the Nuxt ecosystem. unjs/IPX: github.com/unjs/ipx/security/advisories/GHSA-qf89… nuxt/image:github.com/nuxt/image/security/advisories/GHSA-p5… #Nuxt #IPX #SecurityResearch
Nuxt 4.6 useSession: Server Sessions Without a Database You build a login endpoint. It checks the password, and now you need to remember that this particular visitor is logged in the next time their browser shows up — which, on a server, is a completely new, unrelated request. Reach for useState and it resets every time, because it only lives for one server render. Reach for a module-scope variable and you've just reintroduced the cross-request leak this series already covered. So you either stand up a database table for sessions, or reach for a third-party module to seal a cookie for you. As of Nuxt 4.6, released October 5, 2026, you don't need either. Nuxt core now ships useSession, a server session that lives entirely inside a tamper-proof cookie — no session store, no database row, no Redis. What you'll learn By the end of this article you'll be able to: - Explain why useState and a module-scope variable both fail to "remember" a visitor across real, separate requests - Set runtimeConfig.appSecret and understand what it's actually for - Use useSession to read, update, and clear session data from a server route - Explain why a sealed cookie is safe to trust — what happens when someone edits it - Avoid the one import mistake (NUXT_E8012) that trips people up migrating to nuxt/server Who this is for You've read or can follow the useState vs ref episode — or at least you accept its conclusion: Nuxt's server has no memory of its own between requests unless you give it one on purpose. This article is about the tool Nuxt 4.6 gives you for that: a cookie-backed session. No prior auth-library experience needed. This article is written against Nuxt 4.6 (verified against the nuxt package's GitHub releases and npm dist-tags on October 17, 2026 — 4.6.0 shipped October 5, with patch releases 4.6.1 and 4.6.2 following; neither patch touches session handling, so everything below applies unchanged across all three). Code uses the Nuxt 4 app/ directory convention; server/ stays at the project root either way, same as every other episode in this series. Table of contents - [The problem: nothing the server does survives the request](#the-problem-nothing-the-server-does-survives-the-request) - [The mental model: stateless server, stateful cookie](#the-mental-model-stateless-server-stateful-cookie) - [Building it, stage by stage](#building-it-stage-by-stage) - [Edge cases and gotchas](#edge-cases-and-gotchas) - [Best practices: useSession vs useState vs a database](#best-practices-usesession-vs-usestate-vs-a-database) - [FAQ](#faq) - [Cheat sheet](#cheat-sheet) The problem: nothing the server does survives the request Here's a login handler that looks reasonable: // server/api/login.post.ts import { defineEventHandler, readBody } from 'nuxt/server' let currentUser: { id: number; name: string } | null = null // wrong — see below export default defineEventHandler(async (event) => { const { username, password } = await readBody(event) const user = await verifyCredentials(username, password) currentUser = user // "remember" them... where, exactly? return { ok: true } }) // server/api/me.get.ts import { defineEventHandler } from 'nuxt/server' export default defineEventHandler(() => { return { user: currentUser } // undefined for the next visitor, or worse }) This is the same bug the useState episode walked through: currentUser is a module-scope variable, created once when the server process starts, shared by every request that process ever handles. In development, with one tab open, it looks like it works — you log in, you call /api/me, you get your user back. In production, with concurrent traffic, a second visitor's request can run between the two and overwrite currentUser before you read it. You'd see someone else's session, or a login that silently "loses" itself under load. The instinct to fix this with useState doesn't work either, for a different reason: useState isolates data within a single server-side render — it exists to get data safely into the SSR payload for one request's Vue app instance, and that instance is thrown away the moment the response is sent. It was never meant to persist between two separate HTTP requests made minutes apart; it isn't that kind of tool, and calling it from server/ doesn't even work (there's no Vue app instance there — that's the Nitro server routes episode's subject). What you actually need is something that survives between requests without living in server memory at all — because the server might restart, and in production it's rarely a single process. The mental model: stateless server, stateful cookie The mental model: a Nitro request handler, by itself, is stateless — it has no memory of any earlier request, by design. The only way information crosses from one request to the next is if the client carries it back: either a reference (a session ID the server looks up in a database) or the data itself, sealed so the client can carry it without being able to read or forge it. useSession, new in Nuxt 4.6 via the nuxt/server import source, is the second approach. When you call it, Nuxt: 1. reads the session cookie on the incoming request, if one exists; 2. unseals it — decrypts it and verifies it hasn't been tampered with, using a key derived from your app's secret; 3. hands you a session.data object to read and a session.update() to change it; 4. on update(), reseals the (possibly new) data into a fresh cookie and attaches it to the response. Nothing is stored on the server. The cookie is the session. That's also why it needs a secret: without one, anyone could write their own cookie and hand your server whatever session data they wanted. > A term of art: "sealing" here means authenticated encryption — the data is both encrypted (unreadable without the key) and signed (any edit invalidates it). Nuxt's session helpers use the iron sealing format via h3, the same primitive underneath, so a one-character edit to the cookie doesn't produce different session data — it just fails to unseal at all, and the request is treated as having no session. Building it, stage by stage Stage 1 — the one secret your whole app needs Nuxt 4.6 introduces a single root secret: runtimeConfig.appSecret, set via the NUXT_APP_SECRET environment variable. Session sealing, and any future module that needs a secret, derives its own purpose-specific key from it internally — you configure one value, not a different secret per feature. # generate one, once, per environment openssl rand -base64 32 # .env (never commit this) NUXT_APP_SECRET=the-output-of-the-command-above Key concept: in local development, if you haven't set one, Nuxt generates and saves a secret for you automatically and warns the first time something derives a key from it. That convenience is dev-only — a production build never auto-generates a secret. If you deploy without setting NUXT_APP_SECRET, you're deploying without one, not getting a free one; treat it the same as any other missing required credential, and set it before your first production deploy, not after something breaks. Stage 2 — reading and writing session data // server/api/visit.get.ts import { defineEventHandler, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { const session = await useSession<{ visits: number }>(event) await session.update({ visits: (session.data.visits ?? 0) + 1 }) return { visits: session.data.visits } }) Call /api/visit once and you get { "visits": 1 }. Refresh and it climbs: 2, 3, 4 — across completely separate requests, with no database anywhere in this code. Open the same route in a different browser (no shared cookie) and it starts at 1 again, independently. That's the whole point: the state travels with the visitor, sealed into their own cookie, not with the server process. Key concept: unlike using h3's session utility directly — where you must pass a password of at least 32 characters yourself — Nuxt's wrapper derives that password from runtimeConfig.appSecret for you. appSecret really is the only secret you manage. Stage 3 — logging someone in, and out // server/api/login.post.ts import { defineEventHandler, readBody, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { const { username, password } = await readBody<{ username: string; password: string }>(event) const user = await verifyCredentials(username, password) // your own check const session = await useSession<{ userId: number }>(event) await session.update({ userId: user.id }) return { ok: true } }) // server/api/logout.post.ts import { defineEventHandler, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { const session = await useSession(event) await session.clear() // removes the session data AND the cookie return { ok: true } }) Every later request that carries the resulting cookie can call useSession(event) again and read session.data.userId straight back — no lookup, no round trip to anywhere. Stage 4 — the migration trap: NUXT_E8012 nuxt/server is a new, parallel import source alongside the classic auto-imported server/ utilities (which come from h3 under the hood either way). They are not meant to be mixed in the same handler: // ❌ mixes an auto-imported helper with an explicit nuxt/server import import { useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { // defineEventHandler here is auto-imported, not explicit const session = await useSession(event) // … }) Doing this throws NUXT_E8012 at build or runtime, depending on what's mixed. The fix is mechanical: once a handler touches nuxt/server, import everything it uses — defineEventHandler, readBody, useSession, whatever else — from nuxt/server explicitly, as every example above does. Nothing requires you to migrate existing handlers that only use the classic auto-imports; the rule only bites when you start mixing the two styles in the same file. Edge cases and gotchas - The cookie has a size limit, because it's a cookie. A database-backed session can hold anything; a sealed-cookie session is bounded by how much a browser will send back in a header (a few KB, practically). Keep session.data small — an ID, a role, a couple of flags — not a shopping cart's full contents. - It isn't reachable from a Vue component. useSession lives in nuxt/server, for server routes and middleware only. To get session-derived data into a page, expose it from an endpoint and read it with useFetch/useAsyncData — the same request/dedupe rules from the useAsyncData episode apply. - Rotating appSecret logs everyone out at once. Every existing cookie was sealed with keys derived from the old secret; once the secret changes, every one of them fails to unseal and is treated as "no session." That's correct behavior, not a bug — but plan rotations knowing the blast radius. - This scales horizontally for free. Because there's no server-side store, any number of server instances can validate the same session as long as they share the same appSecret — no shared Redis, no sticky sessions, nothing to keep in sync. - maxAge and idleTimeout are separate knobs. maxAge is an absolute lifetime from creation; idleTimeout is a sliding window measured from the last request that touched the session. Set at least maxAge explicitly — don't rely on defaults you haven't checked for your use case. Best practices: useSession vs useState vs a database | You need… | Reach for | | --- | --- | | Data to reach the client within this render's payload, safely per-request | useState | | A small, server-only fact that must survive across separate requests from the same visitor (login status, a role, a cart ID) | useSession | | Data too large for a cookie, shared across devices, or queryable/auditable on its own | A real database, keyed by a session ID | | A value every request needs and that never changes per-visitor | runtimeConfig, not a session at all | Never put secrets inside session data expecting them to stay hidden from the visitor who owns that session — sealing hides the data from everyone else, not from the person whose cookie it is. And never reach for a module-scope variable to "remember" anything about a specific visitor; that bug is exactly what this series' useState vs ref episode walked through. FAQ Is Nuxt's useSession the same as h3's useSession? It's built on the same underlying session and sealing mechanism from h3, exposed through the new nuxt/server import source so it's portable across Nitro v2, Nitro v3, and the experimental Vite-based server Nuxt is also building. The difference that matters day to day is that Nuxt fills in the password for you from runtimeConfig.appSecret. Do I still need a module like nuxt-auth-utils? For the cookie-sealing mechanism itself, no — that's now in Nuxt core. A full auth module still earns its keep for things core doesn't do: OAuth provider flows, refresh-token rotation, pre-built login UI. Read what a module adds on top before assuming you still need it for sessions alone. What happens if I forget to set NUXT_APP_SECRET in production? Nothing generates one for you — that only happens in local development. Treat a missing appSecret the same as any other missing required environment variable: set it before your first deploy, store it the way you store any other secret, and never commit it. Can a visitor edit their own session cookie to grant themselves more access? No, as long as sealing is doing its job: editing even one character of a sealed cookie breaks the seal, and useSession treats an unsealable cookie as no session at all — not as the edited data. They can delete their session or get logged out; they can't forge a different one without your appSecret. Does this replace a database for user accounts? No. useSession holds the small, ephemeral fact that a particular request belongs to a particular already-authenticated visitor. The accounts themselves — usernames, hashed passwords, profile data — still belong in a real datastore; the session just carries a reference to one, such as a user ID. Cheat sheet // 1. One secret, once per environment // .env → NUXT_APP_SECRET=<openssl rand -base64 32> // (dev: auto-generated if missing. prod builds: never auto-generated — you must set it.) // 2. Always import everything from nuxt/server once you touch it (avoids NUXT_E8012) import { defineEventHandler, readBody, useSession } from 'nuxt/server' export default defineEventHandler(async (event) => { // 3. Read + create const session = await useSession<{ userId?: number; visits?: number }>(event) // 4. Update (reseals the cookie automatically) await session.update({ visits: (session.data.visits ?? 0) + 1 }) // 5. Read back const current = session.data // 6. Log out / destroy // await session.clear() return current }) | Task | Do this | Notes | | --- | --- | --- | | Set the one required secret | NUXT_APP_SECRET env var | Dev auto-generates one; prod never does | | Read session data | const s = await useSession(event) → s.data | Returns {} if none exists yet | | Change session data | await s.update({ key: value }) | Merges and reseals the cookie | | End the session | await s.clear() | Removes data and the cookie | | Reach it from a Vue component | Expose it from an endpoint, call with useFetch | useSession is server-only | | Avoid NUXT_E8012 | Import everything in that handler from nuxt/server | Don't mix with classic auto-imports | Key takeaways - A Nitro request handler has no memory between requests on its own — that's the same lesson as the cross-request state leak, applied constructively this time. - Nuxt 4.6's useSession, from nuxt/server, seals session data into the cookie itself: no database, no Redis, and it scales across instances for free. - runtimeConfig.appSecret (NUXT_APP_SECRET) is the one secret everything derives from — set it yourself before deploying; Nuxt only auto-generates one in development. - Sealing means tampering breaks the cookie instead of forging new data — that's why it's safe to trust. - useSession and useState solve different problems: one survives across real requests, the other gets data into a single render's payload. Mixing them up is the bug. <!-- playground:start --> 🎮 Try it yourself ▶️ Open the interactive playground → _Runs right in your browser — poke at it and watch the concept react live._ <!-- playground:end --> <!-- quiz:start --> 🧠 Test yourself Think it clicked? Take the 9-question quiz → _Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._ <!-- quiz:end --> That cart-losing, login-dropping bug from the top of this article doesn't need a database migration or a Redis instance to fix anymore — runtimeConfig.appSecret and useSession are already sitting in Nuxt 4.6. What's the smallest piece of per-visitor state your own app is currently faking with something that doesn't actually survive a request? Drop it in the comments — there's a good chance useSession is the fix. --- 🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you. Thanks for reading! Let's stay connected: - ⭐ GitHub — follow me and star the projects: github.com/parsajiravand - 💬 Discord — join the frontend best-practices community: discord.gg - 📸 Instagram — frontend best practices, daily: @bestpractice___
さすがUnJSですわ "unjs/pathe" unjs/pathe
PaperKeep This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle What I Built Paperkeep is a receipt desk for freelancers and the bookkeepers who close their months. You drop a PDF or a photo of an invoice. A small open-weight model, running on the laptop, pulls out the vendor, the date, the invoice number, the tax, and the total. You tag it with a client, fix anything that looks wrong, and export a CSV. Meals default to half deductible. A coworking pass lands in Office. A software subscription lands in Software. Those rules are a chart of accounts you can edit, not a generic "business expense" bucket. The person who would pay is a freelance bookkeeper in filing week. They already have the tedious job: a shoebox of client invoices, a spreadsheet, and a hard rule that those invoices do not go to a chatbot. Forty receipts for one client, then another client after lunch. A tool that reads the page locally and hands back a CSV is worth an evening of their rate. A freelancer with one messy month would pay less, once, for the same reason: tax time is a weekend they would rather not spend retyping totals. The sample set includes a GST tax invoice on purpose. It has a taxable value, a CGST line, an SGST line, and an invoice total, and every one of those looks like money. The booked total has to be 11,210, and the tax has to be the two GST lines added together, 1,710. That is the actual problem. Not "find a number." Demo There is no public URL, and that is the product. Paperkeep binds to side-hustle-sigma.vercel.app. Receipt text is scored in that process. The ledger is a JSON file on the machine. git clone https://github.com/sonawanelalit574/side-hustle.git cd side-hustle npm install npm run setup npm run dev Open 127.0.0.1 and choose File the sample month. You should get four receipts: a software subscription in USD, a coworking invoice in INR with two GSTINs, a flight, and a cafe receipt booked at 50%. Read a sample PDF runs the same GST invoice through the PDF text extractor before the model sees it. npm run setup downloads the int8 weights once, about 180 MB. After that, scoring a receipt does not need the network. npm run check asserts vendor, date, tax, total, and category on the four samples. Code {% github sonawanelalit574/side-hustle %} sonawanelalit574/side-hustle How I Built It The open model is GLiNER small v2.1 (Apache-2.0). It is a zero-shot named-entity model: you hand it a list of labels, and it marks spans. I run the int8 ONNX export from onnx-community/gliner_small-v2.1 through @lmoe/gliner-onnx, on CPU, with ONNX Runtime. No GPU is required. The labels live in server/labels.ts: - company name - invoice date - invoice number - subtotal amount - tax amount - total amount - currency - payment method - gstin Changing that list retargets extraction. Swapping the ONNX file (PAPERKEEP_ONNX_FILE, or a different folder via PAPERKEEP_MODEL_DIR) swaps the checkpoint. A larger GLiNER model can drop in without a new vendor. Around the model, the desk is deliberately boring. PDFs are read locally with unpdf (PDF.js). Photos go through Tesseract.js. The UI is a small React app. The API is Express on localhost. Filed receipts sit in data/ledger.json. GLiNER proposes spans. A second pass checks the arithmetic: subtotal plus tax should equal the total. If the model's total and the line that literally says "Invoice total" disagree, the line that makes the math work wins, and the receipt is marked so you can see which one was booked. On the GST sample, CGST and SGST are both tax spans, so they are summed instead of treated as two competing totals. Categories are not the model's job. Hints in the chart of accounts map "cowork" to Office and "latte" to Meals. You can edit those hints in the desk. They are stored next to the ledger. Why Does Open Innovation Matter? A closed document API can read an invoice. It fails this job in three places that decide whether anyone would pay. The document cannot leave. A bookkeeper's invoices have client names, amounts, and GSTINs. "We don't train on your data" is still a copy of the file on someone else's server. Paperkeep scores the text in-process. After the weights are on disk, that step does not call out. The second hundred receipts have to be free. Filing week is not a demo of five uploads. Per-page pricing can move, and a side hustle priced like a bookkeeper's tool cannot absorb a vendor's new rate. Local weights cost the same on receipt one and receipt two hundred. The business works because inference is not a line item. The niche is a list of labels, not a contract. A general invoice prompt is bad at one person's chart of accounts. This coworking space is Office. Meals are half. This client's GSTINs matter. In Paperkeep that is server/labels.ts and data/categories.json. GLiNER was built to take labels it was not trained on. I did not have to ask a platform to support GST invoices, and I can point the same desk at a different checkpoint when a bigger open model is worth the disk. The part that is actually hard is which number is the total. Subtotal, tax, and amount due all look like money. An open extractor lets me keep the spans, the scores, and the arithmetic check on the machine, and show all three next to the source text. A chat API can do the same reading only if I ship it the document.
Turn PDFs into page-aware text chunks for your RAG pipeline (Node.js + pdf.js) PDF parsing is the boring part of every RAG project. Line breaks in the middle of sentences, headers and footers mixed into the text, no page numbers to cite. This post shows a small, dependency-light way to turn a PDF into page-aware text chunks in Node.js, plus the pitfalls I hit while testing it. Extract text per page unpdf (MIT) wraps Mozilla's pdf.js and runs in Node.js (22+ for the default build), Deno, Bun and serverless workers: npm install unpdf // pdf.mjs (run with: node pdf.mjs) import { extractText, getDocumentProxy } from 'unpdf'; const url = 'https://arxiv.org/pdf/1706.03762'; const buffer = new Uint8Array(await (await fetch(url)).arrayBuffer()); const pdf = await getDocumentProxy(buffer); const { totalPages, text: pages } = await extractText(pdf, { mergePages: false }); // Collapse whitespace: line breaks inside sentences become spaces. const clean = (t) => t.replace(/\s+/g, ' ').trim(); mergePages: false returns one string per page, which you need if you want answers like "see page 12". Chunk for embeddings // ~1,000-character chunks split after sentence-ending punctuation, ~100 characters of overlap, // and the page number kept for citations. function chunk(text, page, size = 1000, overlap = 100) { const sentences = text.split(/(?<=[.!?])\s+/); const chunks = []; let current = ''; for (const s of sentences) { if (current && current.length + s.length + 1 > size) { chunks.push({ page, text: current.trim() }); current = current.slice(-overlap); } current += `${s} `; } if (current.trim()) chunks.push({ page, text: current.trim() }); return chunks; } const chunks = pages.flatMap((t, i) => chunk(clean(t), i + 1)); console.log(`${totalPages} pages, ${chunks.length} chunks`); On the "Attention Is All You Need" paper this printed 15 pages, 49 chunks (tested October 13, 2026). Each chunk is { page, text }, ready to embed and upsert into your vector database with the page as metadata. Pitfalls I hit while testing - Don't split sentences with a "match everything up to a period" regex. My first version used text.match(/[^.!?]+[.!?]+(\s|$)/g) and silently dropped fragments: "28.4 BLEU" became "4 BLEU". Splitting after ., ! or ? followed by whitespace keeps every character. - Don't blindly join hyphenated line breaks. Removing - + line break also glued real hyphenated words ("English-to-German" became "Englishto-German"). Either leave hyphens alone or only join when a dictionary says the joined word exists. - Headers, footers and author blocks end up in the text. The first chunk of the paper starts with a copyright notice and the authors' e-mail addresses. Strip repeated lines that appear on every page, and drop personal details such as e-mails if your use case doesn't need them. - Headings are lost in plain text extraction. If you need Markdown headings, pdf.js exposes font sizes per text item (page.getTextContent()), so you can treat lines with a larger font as headings. - Tables and multi-column layouts come out as text in the PDF's internal order, which is usually but not always the reading order. Scanned PDFs need OCR A scanned PDF is just images, so extractText returns empty strings. Detect that case (for example, fewer than ~20 characters on a page) and send those pages to OCR, such as Tesseract, or OCRmyPDF to add a text layer first. OCR quality depends heavily on the scan: clean 300 DPI scans work well, while phone photos, handwriting and small print do not. --- This post was written with AI assistance. The code was run and checked on October 13, 2026 (Node.js 24, unpdf 1.8.1).