Flex Engineers
5 min read

Turning Weeks of Qualitative Coding Into Minutes: Building InfoSysCS

Next.jsOpenAI APIRedux ToolkitMaterial UISaaS DevelopmentUser Authentication

The problem: qualitative analysis doesn't scale

Anyone who has done qualitative research knows the bottleneck isn't collecting interviews — it's coding them. A researcher with fifty transcripts from a program evaluation can expect to spend days manually reading, tagging, and clustering quotes into themes before a single insight reaches a stakeholder. It's slow, it's subjective, and it doesn't parallelize well across a team.

InfoSysCS is a web application our team built to compress that process from days into minutes. A researcher uploads up to five interview transcripts (PDF or DOCX), picks the file type, and gets back a structured thematic report — key themes with supporting quotes — generated by an LLM instead of a red pen and a highlighter. It's aimed at researchers, evaluators, and NGO/program staff who need defensible thematic analysis without hiring a coding team.

Architecture at a glance

The frontend is a Next.js 13 (Pages Router) app that talks to a separate backend API for auth, file processing, and OpenAI orchestration. We kept a hard boundary between the two: this repo owns nothing except UI state and API composition.

src/
├── api/            # One file per resource — auth, upload, report, user
├── components/
│   ├── Admin/      # Role-gated dashboards for platform oversight
│   ├── Auth/       # Login / register / modal flows
│   ├── Dashboard/  # Report history for the logged-in researcher
│   └── FileUploader/
├── pages/          # File-based routing + getServerSideProps auth gates
├── store/          # Redux Toolkit + redux-persist
└── utils/          # File validation, id generation, email validation

Every network call lives in src/api/*.js as a plain async function — no hooks, no classes, just axios calls with explicit error handling. That decoupling made it trivial to swap endpoints during development without touching a single component, and it keeps the API surface auditable in one place instead of scattered across fetch calls in components.

Auth: SSR-gated, not client-gated

The interesting decision here was where to enforce authentication. Client-side route guards are easy to bypass and cause a flash of protected content before the redirect kicks in. Instead, every protected page uses Next's getServerSideProps to check the accessToken cookie before the page ever renders:

export const getServerSideProps = wrapper.getServerSideProps((store) => async ({ req, res }) => {
  const cookies = getCookies({ req, res });
  if (!cookies.accessToken) {
    res.setHeader("location", "/login");
    res.statusCode = 302;
    res.end();
    return;
  }
  const user = await handleGetUserData(cookies.accessToken);
  await store.dispatch(setAuthState({ isLogedUser: true, accessToken: cookies.accessToken, user: user.data }));
  return { props: { accessToken: cookies.accessToken, user: user.data } };
});

The admin dashboard uses the same pattern against a separate handleGetAdminData endpoint, and redirects to a 404 rather than a login page — deliberately not revealing that an admin route exists to an unprivileged user. Combined with next-redux-wrapper, the SSR-fetched user is hydrated straight into the Redux store server-side, so there's no client-side loading waterfall for auth state on first paint.

The report-sharing flow

One feature that took more thought than it looks like: reports need to be shareable with people who aren't logged in yet — a common case in program evaluation where a stakeholder gets a link to a specific report before they've ever registered. Rather than making reports publicly accessible by ID (a data leak waiting to happen), the flow issues a short-lived report_auth_token cookie tied to a specific report reference. When that user eventually logs in, the dashboard's SSR handler checks for the cookie and calls connectToReport to bind the report to their account before deleting the token:

if (cookies.report_auth_token) {
  const foundAndConnected = await connectToReport(isAuth, cookies.report_auth_token);
  if (!foundAndConnected) return { props: { accessToken: isAuth, user: user.data } };
  deleteCookie("report_auth_token", { req, res });
}

It's a small mechanism, but it solves a real access-control problem: how do you let someone claim a resource generated before they had an account, without exposing that resource to anyone with the URL.

State management: Redux Toolkit + persist, deliberately minimal

The Redux store holds exactly one slice — auth. Our team resisted the temptation to route all server data (reports, admin lists) through Redux, because that data is inherently server-owned and short-lived per view; funneling it through global state would mean writing cache-invalidation logic for state that SSR and local useState already handle correctly. redux-persist is scoped to the client only (the store factory branches on typeof window === "undefined"), so SSR gets a clean, unpersisted store every request while the browser gets session continuity across reloads.

ConcernWhere it livesWhy
Auth/sessionRedux + redux-persistNeeds to survive reloads, shared across the whole app
Report dataLocal component stateFetched per-page, no benefit to global caching
Form/upload stateLocal component stateEphemeral, single-component lifecycle

Upload validation before the network hop

File validation happens entirely client-side before a request is even built, rejecting mismatched MIME types (application/pdf vs the DOCX OOXML content type) and enforcing the five-file cap with typed errors ({ message, fileError: true }) that the UI can distinguish from server-side validation failures:

function checkAndFormateFiles(files, type) {
  varifyFileInput(files);
  const formData = new FormData();
  for (let i = 0; i < files.length; i++) {
    if (type === "pdf" && !checkIfPdf(files[i].type)) {
      throw { message: "All Files Must Be of Type 'PDF' ", fileError: true };
    }
    // ...
    formData.append("files", files[i]);
  }
  return formData;
}

That fileError flag is a small pattern but it matters: the upload component branches its error UI on whether a failure was a client-side validation issue or a response.data.errors payload from the API, so the user always gets a specific, actionable message instead of a generic "something went wrong."

Role-based access without a permissions library

RBAC here is deliberately simple: two roles, enforced at the SSR boundary via two separate endpoints (/auth/me vs /auth/admin) rather than a single user object with a role flag checked client-side. That means a regular user hitting /admin never receives the admin payload in the first place — there's no client-side branch to reverse-engineer, because the server never sends the data.

What this project demonstrates

InfoSysCS is a case study in keeping a frontend honest about what belongs on the server versus the client: auth decisions happen in getServerSideProps, not in a useEffect redirect; role checks happen by calling a role-scoped endpoint, not by trusting a client-side flag; and the one piece of state that genuinely needs to persist across sessions is the only thing that touches Redux. None of these are exotic techniques, but applying them consistently is what keeps an AI-driven data product — where the trust boundary between "logged in," "invited via link," and "admin" actually matters — from accumulating the kind of client-side security debt that's easy to introduce and expensive to unwind later.