Skip to content

The Edge of the Cyber World See the latest

Apps

React vs Vue Auth Setup: 12 Steps, 100 Min [2026]

Every production app eventually needs the same thing: a login form that works, sessions that survive a page refresh, and routes that stay locked until someone proves who they are. The tricky part is that React and Vue solve this in almost opposite ways in 2026. React 19.2.7 leans on Server Components and Server Actions to keep auth logic off the client entirely. Vue 3.5, paired with Nuxt 4.5, still favors a more explicit split between a server API and a reactive client store built on Pinia. Neither approach is wrong, but the code looks different enough that copying a React auth tutorial into a Vue project (or vice versa) usually breaks something.

This tutorial builds the same JWT-based authentication system twice: once in React 19 + Next.js 16, once in Vue 3.5 + Nuxt 4. You’ll wire up a shared Express-style backend, issue and verify tokens, protect routes, rotate refresh tokens, and add role-based access control. By the end you’ll have two working login flows side by side, plus a clear sense of which parts of each framework’s auth story actually save you time.

Why bother building it twice instead of just picking one? Because most teams don’t get to choose in a vacuum. React still dominates raw adoption, showing up on roughly 6.1% of all websites tracked by usage surveys and used by 44.7% of developers in the Stack Overflow 2025 survey, compared with Vue’s 0.6% of sites and 17.6% developer usage. But plenty of shops standardize on Vue for its gentler learning curve and more opinionated defaults, and a growing number of teams run both, one for a customer-facing product and one for an internal admin tool. If you’re maintaining both, or interviewing at a company that does, seeing the same auth flow built two different ways is worth more than reading either framework’s docs in isolation.

React vs Vue Rendering Architecture: Why It Matters for Auth

Before touching code, it helps to understand why the two implementations in this tutorial look structurally different rather than just syntactically different. React 19’s headline architectural shift is Server Components: components render ahead of bundling, in an environment separate from the browser, and can run once at build time or per request on a web server. Combined with Server Actions (the 'use server' functions used in Step 5), this lets React ship an entire login flow, form handling, password verification, and cookie-setting, without a single explicit API route or client-side fetch call. The auth logic never leaves the server boundary unless you deliberately cross it.

Vue 3.5 takes a more traditional client-server split, even inside Nuxt 4. A Nuxt server route (the .post.ts files in Step 6) is functionally similar to an Express endpoint, and the Pinia store on the client explicitly calls it via $fetch. Vue 3.5 also shipped Vapor Mode, a compiled rendering path that skips the virtual DOM entirely for opted-in components, favoring direct DOM operations tied to reactive sources instead. That’s a performance optimization rather than an architectural philosophy shift, but it does mean a Vue auth form re-rendering on every keystroke can, in principle, update the DOM without the diffing overhead a React form still pays for, even with the React Compiler’s automatic memoization applied. Neither model is objectively better for authentication specifically. React’s approach reduces the surface area where credentials can leak because less code ships to the client; Vue’s approach keeps the request/response boundary explicit and easier to reason about when you’re debugging with browser dev tools open.

Prerequisites: Versions and Tools You Need

Before starting, confirm your toolchain matches what’s current as of August 2026. Mismatched major versions are the single most common reason auth tutorials fail halfway through.

Tool Required Version Notes
Node.js 24.x (Active LTS) Node 22 (Maintenance LTS) also works; avoid Node 26 until it enters LTS in October 2026
React 19.2.7 Latest stable patch as of June 2026, ships with stable Server Components and Actions
Next.js 16.3.3 Active LTS release, patched August 25, 2026 for a libheif/sharp AVIF vulnerability
Vue 3.5.x Includes Vapor Mode, a compiled no-virtual-DOM rendering path for opted-in components
Nuxt 4.5.2 Nuxt 3 reached end-of-life on July 31, 2026 — migrate before starting this tutorial
Pinia 3.x Official Vue state management library, used for the client-side auth store
jsonwebtoken 9.x Node library for signing and verifying JWTs on the shared backend
PostgreSQL or SQLite 16.x / 3.45+ Either works; examples use SQLite for local simplicity

You’ll also need a package manager (npm, pnpm, or bun all work fine), a code editor, and roughly 100 minutes if you’re building both stacks in parallel. If you only care about one framework, expect to cut that time in half. Basic familiarity with JWTs helps — if you’ve never touched one, the JWT introduction guide covers the format in about five minutes.

Step 1: Scaffold the React 19 + Next.js 16 Project

Start with a fresh Next.js app. The App Router is the default in Next.js 16, and Server Components are the default rendering mode for every component you create unless you explicitly opt into "use client".

npx create-next-app@latest react-auth-demo --typescript --app --tailwind
cd react-auth-demo
npm install jsonwebtoken bcryptjs jose
npm install -D @types/jsonwebtoken @types/bcryptjs

The jose package handles JWT verification inside Next.js middleware, since middleware runs on the Edge runtime where the Node.js jsonwebtoken package isn’t fully supported. This split — Node APIs for the backend, Edge-compatible APIs for middleware — trips up a lot of first-time React auth builders, so keep both installed from the start.

Create the folder structure you’ll use for the rest of the tutorial:

mkdir -p app/login app/dashboard app/api/auth lib
touch lib/auth.ts lib/session.ts app/api/auth/login/route.ts

Step 2: Scaffold the Vue 3.5 + Nuxt 4 Project

Now build the Vue equivalent. Nuxt 4.5 uses a slightly different default directory layout than Nuxt 3 (the app/ directory now wraps your pages and components), so if you’re following an older Nuxt tutorial, expect the paths to look unfamiliar.

npx nuxi@latest init vue-auth-demo
cd vue-auth-demo
npm install pinia @pinia/nuxt jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs

Register Pinia in nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  compatibilityDate: '2026-08-01',
  future: { compatibilityVersion: 4 }
})

Nuxt’s server/ directory is the direct equivalent of Next.js’s app/api/ routes — it’s where your login, refresh, and logout endpoints will live. Create the same folder skeleton you built for the React side:

mkdir -p app/pages/login app/pages/dashboard server/api/auth server/utils
touch server/utils/auth.ts server/api/auth/login.post.ts

Step 3: Design the Shared Auth Database and API

Both apps hit the same backend logic, so it’s worth writing the user schema once and reusing it in both projects. A minimal users table needs an id, email, hashed password, and role:

CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'user',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE refresh_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token_hash TEXT NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  revoked BOOLEAN DEFAULT FALSE
);

The refresh_tokens table matters more than most tutorials admit. Storing refresh tokens server-side (hashed, never in plain text) is what lets you revoke a session immediately — say, after a password reset or a stolen-device report — instead of waiting for a 15-minute access token to expire on its own.

Hash passwords with bcrypt at a cost factor of at least 12 before inserting a new user. Never store or log plaintext passwords, even temporarily during debugging. That’s the fastest way to turn a tutorial project into a real breach if the repo ever leaks.

If you’re prototyping quickly, SQLite with a library like better-sqlite3 works fine for both the React and Vue backends and needs no separate database server. For anything past a demo, swap in Postgres. Both frameworks connect to it the same way through an ORM like Prisma or Drizzle, and the schema above translates directly without changes. The one field worth adding early, even if you don’t use it yet, is a last_login_at timestamp on the users table. It costs nothing to store and becomes useful the first time you need to answer “was this account actually compromised, or did the user just forget they logged in from a new device.”

Step 4: Issue and Verify JWTs on the Backend

Both the React and Vue projects call the same token logic, so write it once as a shared utility (or a small standalone service if you want a true single backend for both frontends):

// lib/auth.ts (React) or server/utils/auth.ts (Vue)
import jwt from 'jsonwebtoken'

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!

export function signAccessToken(userId: string, role: string) {
  return jwt.sign({ sub: userId, role }, ACCESS_SECRET, { expiresIn: '15m' })
}

export function signRefreshToken(userId: string) {
  return jwt.sign({ sub: userId }, REFRESH_SECRET, { expiresIn: '30d' })
}

export function verifyAccessToken(token: string) {
  return jwt.verify(token, ACCESS_SECRET) as { sub: string; role: string }
}

Keep the access token lifetime short (10-15 minutes) and the refresh token longer (7-30 days). This is the standard split recommended in the OWASP Session Management Cheat Sheet: a stolen access token expires quickly on its own, while a stolen refresh token can be revoked server-side the moment you notice something’s wrong.

Step 5: Build the React Login Flow

React 19’s Server Actions let you handle a login form submission without writing a separate fetch call or API route on the client. The form posts directly to a server function.

React Login Form Component

// app/login/actions.ts
'use server'
import { cookies } from 'next/headers'
import bcrypt from 'bcryptjs'
import { signAccessToken, signRefreshToken } from '@/lib/auth'
import { getUserByEmail } from '@/lib/db'

export async function loginAction(formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const user = await getUserByEmail(email)
  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    return { error: 'Invalid email or password' }
  }

  const accessToken = signAccessToken(user.id, user.role)
  const refreshToken = signRefreshToken(user.id)

  const cookieStore = await cookies()
  cookieStore.set('access_token', accessToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 60 * 15
  })
  cookieStore.set('refresh_token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 30
  })

  return { success: true }
}

React Auth Context

Because the tokens live in HttpOnly cookies, your React components never touch the raw JWT. Instead, a small Server Component reads the verified user off the cookie and passes it down as a prop — no client-side auth context or global store required for the basic case:

// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import { verifyAccessToken } from '@/lib/auth'
import { redirect } from 'next/navigation'

export default async function Dashboard() {
  const token = (await cookies()).get('access_token')?.value
  if (!token) redirect('/login')

  const { sub, role } = verifyAccessToken(token)
  return 

Signed in as user {sub}, role: {role}

}

Step 6: Build the Vue Login Flow

Vue’s approach separates concerns more explicitly: a Nuxt server route issues the tokens, and a Pinia store on the client tracks the current user’s reactive state for your UI.

Vue Login Form Component

// server/api/auth/login.post.ts
import bcrypt from 'bcryptjs'
import { signAccessToken, signRefreshToken } from '../../utils/auth'
import { getUserByEmail } from '../../utils/db'

export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)
  const user = await getUserByEmail(email)

  if (!user || !(await bcrypt.compare(password, user.password_hash))) {
    throw createError({ statusCode: 401, message: 'Invalid email or password' })
  }

  const accessToken = signAccessToken(user.id, user.role)
  const refreshToken = signRefreshToken(user.id)

  setCookie(event, 'access_token', accessToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 60 * 15
  })
  setCookie(event, 'refresh_token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 30
  })

  return { id: user.id, email: user.email, role: user.role }
})

Pinia Auth Store

// stores/auth.ts
export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null as { id: string; email: string; role: string } | null
  }),
  actions: {
    async login(email: string, password: string) {
      this.user = await $fetch('/api/auth/login', {
        method: 'POST',
        body: { email, password }
      })
    },
    async logout() {
      await $fetch('/api/auth/logout', { method: 'POST' })
      this.user = null
    }
  }
})

Notice the tokens still live in HttpOnly cookies, just like the React version. Pinia only stores the non-sensitive user profile for reactive UI updates, never the JWT itself. That distinction is what keeps both implementations equally resistant to XSS token theft.

Step 7: Choose a Token Storage Strategy

This is the decision that determines how vulnerable your app is to token theft, and it’s the same choice regardless of framework. Three options exist in practice, and only one is recommended for production.

Storage Location XSS Risk CSRF Risk Verdict
localStorage / sessionStorage High — any injected script can read it None Avoid for production auth
In-memory (React state / Pinia state) Low, but lost on refresh unless paired with a refresh flow None Good for the access token alone
HttpOnly, Secure, SameSite cookie None — JavaScript cannot read it Mitigated by SameSite=strict Recommended for both access and refresh tokens

The pattern used throughout this tutorial — HttpOnly cookies for both tokens — eliminates the XSS token-theft vector entirely, since client-side JavaScript in either framework simply cannot read the cookie’s contents. The tradeoff is that you lose the ability to inspect the token from the browser console during debugging, which is a fair price for the security gain. See the MDN cookies reference for the full attribute list.

Step 8: Protect Routes in Both Frameworks

Route protection is where React and Vue diverge the most visibly. React checks the cookie inside Edge middleware before a request ever reaches a page. Vue and Nuxt use a named middleware function attached per-route or globally.

React Middleware

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'

export async function middleware(req: NextRequest) {
  const token = req.cookies.get('access_token')?.value
  if (!token) return NextResponse.redirect(new URL('/login', req.url))

  try {
    await jwtVerify(token, new TextEncoder().encode(process.env.JWT_ACCESS_SECRET))
    return NextResponse.next()
  } catch {
    return NextResponse.redirect(new URL('/login', req.url))
  }
}

export const config = { matcher: ['/dashboard/:path*'] }

Vue Nuxt Middleware

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const authStore = useAuthStore()
  if (!authStore.user && to.path.startsWith('/dashboard')) {
    return navigateTo('/login')
  }
})

Register it per-page with definePageMeta({ middleware: 'auth' }), or apply it globally by renaming the file to auth.global.ts. The React version verifies the token cryptographically on every request at the Edge; the Nuxt version above checks a client-side store, so for a route that must be secure even on first paint, pair it with a server-side check inside the page’s useAsyncData call or a matching server middleware in server/middleware/.

Step 9: Add Refresh Token Rotation

A short-lived access token is only half the story. Once it expires, the frontend needs to silently exchange the refresh token for a new one, without forcing the user to log in again. Rotation — issuing a brand-new refresh token every time the old one is used, and revoking the old one — closes the window an attacker has if a refresh token ever leaks.

// Shared refresh handler (Next.js route or Nuxt server route)
export async function refreshHandler(refreshToken: string) {
  const stored = await findRefreshToken(refreshToken)
  if (!stored || stored.revoked || stored.expires_at < new Date()) {
    throw new Error('Refresh token invalid or expired')
  }

  await revokeRefreshToken(stored.id)
  const newAccessToken = signAccessToken(stored.user_id, stored.role)
  const newRefreshToken = signRefreshToken(stored.user_id)
  await storeRefreshToken(stored.user_id, newRefreshToken)

  return { newAccessToken, newRefreshToken }
}

Wire this into a /api/auth/refresh route in React or a server/api/auth/refresh.post.ts handler in Nuxt, then call it automatically whenever a request comes back with a 401. In React, an Axios or native fetch interceptor works well for this; in Nuxt, wrap your $fetch calls with an onResponseError hook that retries once after a successful refresh.

Step 10: Add Role-Based Access Control

Most apps need more than “logged in or not.” An admin dashboard, a billing page, or a moderation tool usually needs to check the user’s role too. Since the role is already embedded in the access token payload from Step 4, RBAC becomes a one-line check wherever you already verify the token.

// React: inside a Server Component or route handler
const { sub, role } = verifyAccessToken(token)
if (role !== 'admin') {
  redirect('/dashboard')
}

// Vue: inside server/api/admin/*.ts
const payload = verifyAccessToken(getCookie(event, 'access_token'))
if (payload.role !== 'admin') {
  throw createError({ statusCode: 403, message: 'Forbidden' })
}

Keep the role check on the server in both frameworks. A client-side-only role check, like hiding a button in the UI, is a usability nicety rather than a security boundary. Anyone can call the API directly with dev tools open, so the server must reject the request independently every time.

Step 11: Handle Logout and Session Expiry

Logout needs to do two things: clear the cookies on the client and revoke the refresh token on the server. Skipping the second step means a stolen refresh token keeps working even after the legitimate user logs out.

// Works nearly identically in both frameworks
export async function logoutHandler(refreshToken: string, clearCookie: (name: string) => void) {
  await revokeRefreshTokenByValue(refreshToken)
  clearCookie('access_token')
  clearCookie('refresh_token')
}

For session expiry on the frontend, both stacks benefit from a simple UX touch: when a refresh attempt fails, meaning the refresh token itself expired or was revoked, redirect to /login?expired=true rather than a generic error page, so returning users understand why they’re seeing the login form again.

Step 12: Test the Complete Flow End to End

Run both dev servers and walk through the full cycle manually before writing automated tests: register a user, log in, hit a protected route, wait for the access token to expire (or shorten it to 30 seconds temporarily to speed this up), confirm the silent refresh works, then log out and confirm the protected route redirects again.

# React
npm run dev
# -> http://localhost:3000/login

# Vue
npm run dev
# -> http://localhost:3000/login (use a different port if running both)

Expected output after a successful login in either stack, inspected via your browser’s Application/Storage tab:

Cookie: access_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Strict
Cookie: refresh_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Strict

GET /dashboard -> 200 OK
"Signed in as user 7f3a2e, role: user"

If you see the cookie in the response headers but the dashboard still redirects to /login, jump to the troubleshooting section below. That specific symptom has a handful of common causes.

React vs Vue Authentication Ecosystem Compared

Beyond hand-rolled JWT auth, both ecosystems have mature third-party libraries that handle OAuth providers, magic links, and session storage for you. Here’s how the major options stack up in mid-2026.

Library Framework Handles Notes
Auth.js (NextAuth) React / Next.js OAuth, credentials, database sessions Default choice for Next.js apps, covered in the Auth.js docs
Clerk React, Vue, others Full user management UI + auth Hosted, fastest to ship, has a free tier with usage limits
Nuxt Auth Utils Vue / Nuxt Session cookies, OAuth Official Nuxt module, lightweight compared to Auth.js
Supabase Auth React, Vue, framework-agnostic Email, OAuth, magic links, RLS-backed sessions Pairs auth with a Postgres database out of the box
Firebase Authentication React, Vue, framework-agnostic Email, OAuth, phone auth Mature, but ties you to Google Cloud infrastructure

If you’re building from scratch for a portfolio project or a small internal tool, the hand-rolled approach from this tutorial is worth understanding even if you eventually swap in Auth.js or Clerk. It makes debugging those libraries far less mysterious when something goes wrong in production. Most teams that start with a hosted provider like Clerk or Supabase Auth still end up reading through the underlying JWT flow at least once, usually the first time a support ticket mentions a session that won’t expire or a token that verifies locally but fails in a different region.

5 Common Pitfalls When Building Auth in React or Vue

These mistakes show up in both React and Vue codebases equally, since none of them are framework-specific. They’re also the five most common causes of a “quick” security fix turning into a multi-day incident response.

  • Storing JWTs in localStorage “just for now.” This almost never gets fixed before shipping. Start with HttpOnly cookies from day one, even in a prototype, so you don’t have to retrofit it later under deadline pressure.
  • Forgetting SameSite on cross-origin setups. If your React or Vue frontend lives on a different subdomain than your API, SameSite=Strict will silently block the cookie from being sent. Use SameSite=Lax or configure a proper reverse proxy so frontend and API share an origin.
  • Checking auth state only on the client. A Pinia store or React context that gates a page is a UX convenience, not a security control. Always re-verify the token server-side for any request that touches real data.
  • Skipping refresh token revocation on password change. If a user changes their password because they suspect a compromise, every existing refresh token needs to be invalidated immediately, not just the one tied to the current session.
  • Mixing Node-only and Edge-only crypto APIs. Next.js middleware runs on the Edge runtime, which doesn’t support the Node crypto module used by jsonwebtoken. Use jose for any verification that happens inside middleware, and reserve jsonwebtoken for server-only routes.

Troubleshooting: 8 Auth Errors and How to Fix Them

Even a correctly-built auth flow throws confusing errors the first few times you run it, usually because the browser, the server, and the token verification logic are each failing silently in a different place. The table below covers the eight errors most likely to show up while working through Steps 5 through 11, in both the React and Vue versions of the project.

Symptom Likely Cause Fix
Dashboard redirects to /login despite a valid cookie Middleware matcher path excludes the route, or cookie name mismatch Double-check the matcher config and confirm the cookie name is identical in both the set and read calls
“jwt malformed” error on verify Access and refresh secrets were swapped, or the token was truncated by a cookie size limit Confirm you’re verifying with the correct secret constant and that the JWT payload isn’t oversized (keep it under 4KB)
Cookie never appears in the browser Missing Secure flag while testing on plain HTTP localhost Use secure: process.env.NODE_ENV === 'production' so local dev over HTTP still works
CORS error on the login request Frontend and API on different ports without CORS headers configured Add explicit CORS middleware allowing your frontend origin and credentials: true
Refresh loop that never resolves Refresh handler itself returns a 401, triggering another refresh attempt Exclude the refresh endpoint from your interceptor’s retry logic entirely
Nuxt middleware runs before Pinia store hydrates Middleware checks authStore.user before the client-side store has loaded from the server Use a server-side session check (via useAsyncData or Nuxt server middleware) rather than relying solely on client store state
Server Action silently does nothing on submit Missing 'use server' directive at the top of the actions file Confirm the directive is the first line of the file, not just inside the function body
“Cannot read properties of undefined (reading ‘sub’)” after login Token verification succeeded but the payload shape doesn’t match what the code expects Log the raw decoded payload once during development to confirm field names match your sign calls exactly

Advanced Tips for Production-Grade Auth

Once the basic flow works in both frameworks, a few upgrades separate a tutorial project from something you’d actually deploy. First, add rate limiting to the login and refresh endpoints. Five failed attempts per IP per minute is a reasonable starting point, and it stops brute-force credential stuffing before it becomes expensive. Second, rotate your JWT signing secrets periodically and support verifying against both the old and new secret during a grace window, so you’re not forced to log every user out simultaneously during a rotation.

Third, consider moving from symmetric HMAC signing (HS256) to asymmetric signing (RS256 or ES256) once you have more than one service verifying tokens. It lets your other microservices hold only the public key, never the private signing key, which shrinks your blast radius if any one service is compromised. Fourth, log authentication events, including successful logins, failed attempts, token refreshes, and revocations, to a separate audit table or logging pipeline. When something does go wrong, that history is often the only way to reconstruct what happened.

Finally, on the Vue side specifically, consider opting your login and dashboard components into Vue 3.5’s Vapor Mode once your app is otherwise stable. Vapor Mode compiles templates directly to imperative DOM operations instead of diffing a virtual DOM tree, and early 2026 benchmarks show DOM update performance gains of up to 36% on eligible components. A login form re-rendering on every keystroke of a password field is a reasonable candidate to test it on.

Deploying Your Auth System to Production

A tutorial project running on localhost hides a handful of problems that only surface once you deploy. Run through this checklist before pushing either app live.

  • Move secrets out of .env files and into your host’s secret manager. Vercel, Netlify, and most Nuxt-friendly hosts all support encrypted environment variables. Never commit JWT_ACCESS_SECRET or JWT_REFRESH_SECRET to git, even in a private repo.
  • Confirm your cookies work across your actual domain setup. If the frontend is on app.example.com and the API is on api.example.com, set the cookie Domain attribute to .example.com so both subdomains can read it, and double-check SameSite still permits the requests you need.
  • Put the login and refresh endpoints behind rate limiting at the edge. Both Vercel and most Nuxt hosting providers support edge-level rate limiting or a lightweight middleware check, which stops credential-stuffing traffic before it reaches your database.
  • Run a database migration for the refresh_tokens table in your production database, not just your local SQLite file. This is an easy step to forget when moving from the SQLite examples in this tutorial to a hosted Postgres instance.
  • Enable HTTPS everywhere, with no exceptions. The Secure cookie flag silently does nothing over plain HTTP, so any environment without HTTPS will quietly stop setting cookies at all, which looks identical to a login bug during debugging.

Once these are in place, both the React and Vue versions of this project are close to what a small production app would actually run. The remaining gap, mostly OAuth providers, password reset flows, and email verification, is where a library like Auth.js or Supabase Auth starts paying for itself instead of adding overhead.

The Complete Working Project Structure

Once you’ve worked through all twelve steps, your two projects should look like this:

react-auth-demo/
|-- app/
|   |-- login/
|   |   |-- page.tsx
|   |   `-- actions.ts
|   |-- dashboard/
|   |   `-- page.tsx
|   `-- api/auth/
|       |-- login/route.ts
|       |-- refresh/route.ts
|       `-- logout/route.ts
|-- lib/
|   |-- auth.ts
|   |-- session.ts
|   `-- db.ts
`-- middleware.ts

vue-auth-demo/
|-- app/
|   |-- pages/
|   |   |-- login.vue
|   |   `-- dashboard.vue
|   `-- middleware/
|       `-- auth.ts
|-- server/
|   |-- api/auth/
|   |   |-- login.post.ts
|   |   |-- refresh.post.ts
|   |   `-- logout.post.ts
|   `-- utils/
|       |-- auth.ts
|       `-- db.ts
|-- stores/
|   `-- auth.ts
`-- nuxt.config.ts

Both trees mirror each other almost line for line. That symmetry is intentional, and it’s the fastest way to see exactly where the two frameworks’ philosophies diverge: React folds auth logic into Server Actions colocated with the page, while Vue keeps it in a dedicated server/ API layer talking to a separate Pinia store.

Frequently Asked Questions

Is JWT authentication still the recommended approach in 2026?
Yes, for stateless APIs and apps that need to scale across multiple servers without shared session storage. For simpler apps, server-side session cookies backed by a database (what Auth.js calls “database sessions”) remain a valid, arguably simpler alternative. JWTs mainly earn their complexity when you need to verify identity across independent services.

Can I use the same backend API for both a React and a Vue frontend?
Yes. Nothing in Steps 3 and 4 is framework-specific. The database schema and token-signing logic work identically whether the frontend making the request is Next.js or Nuxt. That’s exactly why the demo splits the shared logic out from the framework-specific login and middleware code.

Why use HttpOnly cookies instead of the Authorization header with a Bearer token?
Bearer tokens sent via an Authorization header must be stored somewhere accessible to JavaScript, whether that’s state, memory, or localStorage, which reopens the XSS attack surface HttpOnly cookies close off. Cookies also get sent automatically by the browser, simplifying every subsequent request.

Does React 19’s React Compiler change anything about this tutorial?
Not directly. The Compiler, which reached stable release in late 2025, automatically handles memoization of expensive renders, but it doesn’t touch authentication logic. It’s worth enabling on your dashboard components regardless, since it removes the need to manually wrap components in useMemo or useCallback.

Do I need Nuxt specifically, or can I do this in plain Vue 3.5?
Plain Vue via Vite works for the client-side pieces. The Pinia store and login form translate directly. You’ll lose the built-in server/ API layer, though, so you’d need a separate backend to issue and verify tokens, similar to what a non-Next.js React SPA would also require.

How long should access tokens and refresh tokens last?
10-15 minutes for access tokens and 7-30 days for refresh tokens is the common range in production systems as of 2026. Shorter access token lifespans reduce the damage window if one leaks, while refresh token rotation from Step 9 keeps the longer-lived token from becoming a standing liability.

Is it safe to store the user’s role inside the JWT payload?
Yes, as long as the token is signed, not just encoded, and you verify the signature on every request. A signed JWT can’t be tampered with client-side without invalidating the signature. Just remember to re-issue the token if a user’s role changes mid-session, since the old token still carries the stale role until it expires.

What’s the fastest way to add social login to either stack?
Auth.js on the React and Next.js side, and Nuxt Auth Utils or Supabase Auth on the Vue side, both handle OAuth provider setup with a few lines of config rather than hand-rolling the OAuth dance yourself. It’s worth adopting once your hand-built credentials flow from this tutorial is working end to end.

Which framework is actually faster for a login-heavy app in 2026?
Raw framework speed rarely decides this in practice. React’s Server Components remove client-side JavaScript for auth screens almost entirely, which helps first-load performance, while Vue 3.5’s Vapor Mode speeds up re-renders on components that update frequently, like a live password-strength meter. For a typical login and dashboard flow, the bigger performance factor is usually your database query latency and JWT verification overhead, not which frontend framework renders the form.

Related Coverage