DEV Community

Cover image for How we built role-based access control and offline-first PWA sync for a multi-tenant pharmacy SaaS in Next.js
ABIN RAJ
ABIN RAJ

Posted on

How we built role-based access control and offline-first PWA sync for a multi-tenant pharmacy SaaS in Next.js

When we started building MedExpiry — a PWA pharmacy inventory management SaaS — two architectural problems showed up early and refused to go away:

  1. How do you enforce role-based access across a genuinely multi-tiered user hierarchy without a database call on every request?
  2. How do you handle offline data in a domain where stale information has real-world consequences?

This post is about how we thought through both problems. No silver bullets — just the decisions, the tradeoffs, and what we'd do differently.


The role hierarchy we had to model

Pharmacy organizations aren't flat. A pharmacy chain has a corporate owner overseeing multiple branches. Each branch has a manager. Each manager oversees pharmacists and support staff. Our role model had to reflect that reality:

super_admin      → platform operator
account_admin    → manages a pharmacy chain
shop_owner/Admin → manages a single branch
Employee         → limited access, expiry tracking only
Enter fullscreen mode Exit fullscreen mode

Each role lives in a completely separate namespace — different routes, different data, different offline stores. The core requirement was hard isolation: a shop_owner from one pharmacy must never touch data from another, even accidentally.


Middleware: the first gate

Next.js middleware runs at the edge before any page renders. We use it for coarse-grained routing enforcement — no React, no database, just a JWT read and a redirect decision.

The pattern looks like this:

// Map of base paths to roles that can access them
const PATH_ROLE_MAP = {
  '/pharmacyadmin': ['account_admin'],
  '/shopadmin':     ['shop_owner', 'Admin'],
  '/employees':     ['Employee'],
};

// Where each role lands after login
const ROLE_DEFAULT_PATHS = {
  'account_admin': '/pharmacyadmin',
  'shop_owner':    '/shopadmin',
  'Employee':      '/employees',
};

export async function middleware(req) {
  const token = req.cookies.get('token');
  if (!token) return redirectTo(req, '/login');

  const payload = decodeJWT(token);
  if (!payload?.role) return redirectTo(req, '/login');

  // Root path → send to role's home
  if (req.nextUrl.pathname === '/') {
    return redirectTo(req, ROLE_DEFAULT_PATHS[payload.role] ?? '/login');
  }

  // Check if role is allowed on this path
  for (const [path, allowedRoles] of Object.entries(PATH_ROLE_MAP)) {
    if (req.nextUrl.pathname.startsWith(path)) {
      if (!allowedRoles.includes(payload.role)) {
        return redirectTo(req, ROLE_DEFAULT_PATHS[payload.role] ?? '/login');
      }
      break;
    }
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

The key decision here: role comes from the JWT, not the database. No async DB call, no latency, no failure mode at the gate. The tradeoff is that role changes don't take effect until the token expires — for a pharmacy SaaS, that's acceptable.


The harder problem: role-scoped offline caching

MedExpiry is a PWA, so it has to work without a connection. That's where the real complexity lives.

The service worker needs to know the current role — and cache only what that role is allowed to see. We solved this by giving every role its own isolated cache namespace:

// Each role gets completely separate caches
const getRoleCacheNames = (role) => ({
  api:   `myapp-api-${role}-v1`,
  pages: `myapp-pages-${role}-v1`,
  db:    `offlineQueue-${role}`,
});
Enter fullscreen mode Exit fullscreen mode

And each role has a config object that declares exactly what it's allowed to cache:

const ROLE_CONFIG = {
  shop_owner: {
    cachedEndpoints: ['/api/inventory', '/api/bills', '/api/expiry'],
    cachedPages:     ['/shopadmin', '/shopadmin/inventory', '/shopadmin/expiry'],
    offlineQueues:   ['pendingInventory', 'pendingExpiry'],
  },
  Employee: {
    cachedEndpoints: ['/api/expiry'],
    cachedPages:     ['/employees/expiry'],
    offlineQueues:   ['pendingExpiry'],
  },
  // other roles follow the same pattern
};
Enter fullscreen mode Exit fullscreen mode

An Employee caches one endpoint and one page. A shop_owner caches their full inventory and billing data. The configs are completely separate — no shared state between roles or tenants.

When a user logs in, the service worker receives their role via a postMessage, clears all other role caches, and sets up only the current role's data. On logout, every role cache is wiped — not just the current one. A new user on the same device always starts clean.


Offline POST handling: queue first, sync later

When a pharmacist adds inventory or logs an expired product while offline, we intercept the POST in the service worker, store it in IndexedDB, and return a success response immediately so the user can keep working:

// Service worker POST handler (simplified)
try {
  const response = await fetch(request.clone());
  if (!response.ok) throw new Error('Network failed');
  return response;
} catch (_) {
  // Offline: queue the request
  const data = await request.clone().json();
  await storeInQueue(offlineStore, requestUrl, data);

  return new Response(JSON.stringify({
    success: true,
    offline: true,
    message: 'Saved offline. Will sync when back online.',
  }), { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

When connectivity returns, the online event fires and sync runs automatically:

self.addEventListener('online', async () => {
  const pending = await getAllPendingItems();
  if (pending.length > 0) await syncAll();
});
Enter fullscreen mode Exit fullscreen mode

No polling, no recurring timer. The browser fires online reliably — we trust it.


The data integrity decision

Early on we considered a simple "serve cache indefinitely while offline" approach. The problem: pharmacy stock data has real-world consequences if it's stale. A pharmacist dispensing medicine based on old inventory could miss an expiry flag or over-dispense.

Our solution was to timestamp every cached API response and treat anything older than one hour as expired:

const age = Date.now() - parseInt(cachedResponse.headers.get('cached-at'), 10);

if (age > 3_600_000) {
  // Don't serve silently stale data — tell the user to reconnect
  return errorResponse('Cached data expired. Please reconnect.');
}

return cachedResponse;
Enter fullscreen mode Exit fullscreen mode

Serving stale pharmacy data silently felt like the wrong tradeoff. An honest error is safer than confident but wrong inventory counts. The 1-hour TTL was a product decision, not a technical one — what's your domain's tolerance?


Three things we'd do differently

Define a permission matrix early. We started with roles hardcoded into route arrays. As the app grew, changing one permission meant touching five files. A { action: [roles] } map would have scaled much better from the start.

Design the service worker config as pure data. Our ROLE_CONFIG object means adding a new role is a one-line change. We didn't design it that way from day one — we refactored into it. Start there.

Decide your cache TTL before you write a line of code. It sounds like a detail. It shapes everything about how your offline UX feels and how much you can trust the data your UI shows.


If you're building in healthtech or have handled role-scoped offline sync differently, I'd genuinely like to hear your approach in the comments.

We're currently in free beta at medexpiry.com — a PWA pharmacy inventory SaaS with the architecture described above running in production. All feedback welcome.

Top comments (0)