Folio is a research workspace by NUS Technology for turning personal source material into grounded answers, traceable notes, and a working notebook.
Users organize research into spaces, add files, web pages, or manual text as sources, and ask questions against either an entire space or one selected source. Answers stream into the interface with citations that link back to the exact evidence passage. Useful answers can be saved as notes, and notes can be converted into reusable sources.
This repository contains the Folio web application. It is a Next.js App Router frontend that talks to the Folio backend through a same-origin /api/* route in development and production.
- Company: NUS Technology
- Repository: nustechnology/Folio-Web
- Backend: nustechnology/Folio-BE
- Production: https://folio.nustechnology.com
- Tech stack
- Getting started
- Environment configuration
- Project structure
- System architecture
- Core product flows
- Authentication and API client
- Application routes
- Backend API surface
- Development commands
- Testing and quality checks
- Project conventions
- Production
- Troubleshooting
- License
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2.12 with the App Router |
| UI runtime | React 19.2.4, React DOM 19.2.4 |
| Language | TypeScript 5 in strict mode |
| Styling | Tailwind CSS 4, CSS design tokens, tw-animate-css |
| UI primitives | Radix UI, reusable shadcn-style components, Lucide icons |
| Server state | TanStack Query 5 |
| Forms and validation | React Hook Form 7, Zod 4, @hookform/resolvers |
| Rich text | Tiptap 3, Marked, isomorphic DOMPurify |
| Streaming | Native SSE reader for answers, @microsoft/fetch-event-source for source-processing status |
| Authentication | Bearer access and refresh tokens stored in browser cookies |
| Feedback | Sonner toast notifications |
| Code quality | ESLint 9, Next.js Core Web Vitals rules, Prettier 3, unused-import checks |
| Package manager | Yarn 4.12.0 with the node-modules linker |
| Runtime packaging | Multi-stage Docker build with Next.js standalone output |
The reference development environment uses:
| Tool | Version | Installation |
|---|---|---|
| Node.js | 22.17.1 | nvm install 22.17.1 |
| Yarn | 4.12.0 | corepack enable && corepack prepare yarn@4.12.0 --activate |
The repository does not include an .nvmrc or pin packageManager in package.json. Keep local development on Node.js 22 and Yarn 4 to match the Docker build. Yarn is configured through .yarnrc.yml to install a conventional node_modules directory instead of using Plug'n'Play.
git clone git@github.com:nustechnology/Folio-Web.git
cd Folio-Web
nvm use 22.17.1
corepack enable
corepack prepare yarn@4.12.0 --activate
yarn install
yarn devOpen http://localhost:3000.
The frontend expects the Folio backend at http://localhost:3001 by default. next.config.ts proxies browser requests from /api/* to that backend, so local frontend requests remain same-origin.
yarn build
yarn startThe build uses output: 'standalone', which emits the minimal server bundle consumed by the production Docker image.
No environment file is required for normal local development. Copy .env.example to .env.local only when the defaults do not match your setup.
| Variable | Default | Purpose |
|---|---|---|
API_PROXY_TARGET |
http://localhost:3001 |
Backend origin used by the Next.js /api/* rewrite |
ALLOWED_DEV_ORIGINS |
— | Additional comma-separated hosts allowed to load Next.js development assets |
NEXT_PUBLIC_API_URL |
/api/v1 |
Overrides the browser API base when the backend is not available behind /api/* |
COMPOSE_FILE |
— | VPS only: .env holds COMPOSE_FILE=docker-compose.prod.yml so bare Compose commands select the production stack |
Keep NEXT_PUBLIC_API_URL unset when the frontend and backend share an origin. Because NEXT_PUBLIC_* values are embedded during the build, changing this value in production requires rebuilding the image.
Do not commit .env, .env.local, tokens, or other credentials.
Expose the frontend port only:
ngrok http 3000The frontend continues to reach the backend through the /api/* rewrite. ngrok and Cloudflare tunnel hostnames are already included in allowedDevOrigins; use ALLOWED_DEV_ORIGINS for other hosts such as a LAN address.
A same-origin write still carries the public tunnel URL in its Origin header. The backend CORS allowlist must therefore accept that origin as well.
folio-web/
├── app/ # App Router pages and route layouts
│ ├── layout.tsx # Root providers, fonts, toast viewport
│ ├── page.tsx # Public landing page
│ ├── login/ # Login server page + client form
│ ├── sign-up/ # Sign-up server page + client form
│ └── spaces/
│ ├── page.tsx # Space list
│ ├── layout.tsx # Source-processing SSE provider
│ └── [id]/
│ ├── sources/ # Source list and evidence reader
│ ├── ask/ # Conversation history and streamed Q&A
│ ├── notes/ # Notes and answer-to-note workflow
│ └── notebook/ # Auto-saving research notebook
├── components/
│ ├── Ask/ # Composer, messages, citations, history
│ ├── Auth/ # Auth forms and password controls
│ ├── Note/ # Note list, editor, viewer, conversion
│ ├── Notebook/ # Editor, toolbar, save state, export
│ ├── Source/ # Source CRUD, progress, reader blocks
│ ├── Space/ # Space CRUD, grid, shell, sorting
│ └── shared/ # App chrome, search, pagination, UI primitives
├── features/ # Domain data and state modules
│ ├── Ask/
│ ├── Auth/
│ ├── Note/
│ ├── Notebook/
│ ├── Passage/
│ ├── Source/
│ └── Space/
├── lib/
│ ├── api/ # Typed fetch client, tokens, refresh, base URL
│ ├── query/ # QueryClient factory and provider
│ └── utils.ts # Shared class and error helpers
├── hooks/ # Cross-domain client hooks
├── utils/ # Pure auth, date, source, and Markdown helpers
├── public/ # Static assets
├── next.config.ts # Rewrite, dev origins, standalone output
├── Dockerfile # Production multi-stage image
├── docker-compose.prod.yml # Frontend-only VPS Compose service
├── eslint.config.mjs # ESLint 9 flat configuration
├── postcss.config.mjs # Tailwind CSS 4 PostCSS plugin
└── package.json # Dependencies and Yarn scripts
Each feature follows the same data-layer shape:
features/<Domain>/
├── types.ts # Domain and API contract types
├── <domain>.service.ts # Pure typed API calls
├── <domain>.mutation.ts # TanStack Query queries and mutations
├── use<Domain>.ts # Consumer-facing hooks
└── index.ts # Public barrel exports
Ask contains separate service and query modules for streamed answers and conversation history. Source additionally owns the global source-processing status provider, while Notebook owns its debounced auto-save hook.
flowchart LR
User["Researcher"]
subgraph Browser["Browser"]
Pages["Next.js App Router<br/>server pages"]
UI["React client components<br/>forms and editors"]
Providers["AuthProvider · QueryProvider<br/>SourceStatusProvider"]
Features["Consumer hooks<br/>TanStack Query<br/>service functions"]
Client["Typed API client<br/>auth · refresh · timeout · SSE"]
end
subgraph FrontendRuntime["Next.js frontend :3000"]
Rewrite["/api/* rewrite<br/>development only in practice"]
end
Proxy["Nginx<br/>production /api routing"]
Backend["Folio backend<br/>default :3001"]
User --> Pages
Pages --> UI
UI --> Providers
Providers --> Features
Features --> Client
Client -->|"Development /api/v1"| Rewrite
Rewrite --> Backend
Client -.->|"Production same-origin /api/v1"| Proxy
Proxy --> Backend
Backend -->|"Answer token stream"| Client
Backend -->|"Source status events"| Providers
The main boundaries are intentional:
- Pages remain Server Components and pass route data into focused Client Components when interactivity is required.
- UI components consume domain hooks rather than calling the API client directly.
- Service modules know endpoint paths and payload types but contain no React or cache logic.
- TanStack Query modules own caching, query keys, invalidation, and mutations.
- The shared API client owns bearer headers, token refresh, request cancellation, timeouts, response parsing, and normalized errors.
- The backend remains a separate service. In development Next.js forwards
/api/*; in production Nginx routes the same path before Next.js sees it.
flowchart LR
Component["Domain component"] --> Hook["Consumer hook"]
Hook --> Query["Query / mutation hook"]
Query --> Service["Service function"]
Service --> Api["Shared API client"]
Api --> Backend["Folio backend"]
Backend --> Api
Api --> Service
Service --> Query
Query -->|"cache update / invalidation"| Component
- A user adds a file, URL, or manual text to a research space.
- The source enters
added,extracting_text,indexing_evidence, and finallyreadyorfailed. SourceStatusProviderkeeps one authenticated SSE connection open at/sources/statusand exposes per-source progress to the UI.- A transition to
readyorfailedinvalidates the source list, source detail, and space queries. - Ready sources expose structured content and evidence passages in the source reader.
- Citation links use
#evidence-passage-<id>anchors to scroll to and highlight the exact supporting passage.
sequenceDiagram
autonumber
participant User
participant UI as Ask UI
participant API as Shared API client
participant Backend as Folio backend
User->>UI: Submit a question
UI->>API: POST /spaces/:id/ask
API->>Backend: JSON payload + bearer token
Backend-->>API: start event
API-->>UI: Conversation and message IDs
Backend-->>API: token events
API-->>UI: Append answer text progressively
Backend-->>API: citations event
API-->>UI: Attach evidence links
Backend-->>API: done event
API-->>UI: Replace draft with canonical answer
Questions may target the whole space or one source. The closing done frame is authoritative because the backend may normalize citation markers after selecting the final evidence set. Users can rate an answer, open its citations, or save it to Notes with the citation mapping intact.
- Notes may be written manually or created from an assistant answer.
- Rich-text note content is rendered through Tiptap and sanitized before display.
- A note can be converted into a source; the backend snapshots the stored note content.
- Each space has one working notebook with debounced auto-save.
- Notebook saves are serialized to avoid overlapping requests, flushed on navigation or tab hide, and blocked when the document exceeds the backend limit.
- Notebooks can be exported as Markdown or through the browser print dialog for PDF output.
Folio uses access and refresh tokens returned by the backend:
- Login returns tokens; sign-up returns tokens plus the newly created user.
- The browser stores both tokens in same-site cookies. Secure cookies are enabled in production.
- Authenticated requests attach
Authorization: Bearer <access token>. AuthProviderloads/users/meafter an access token appears.- On a
401with the backend error codeTOKEN_EXPIRED, the API client deduplicates concurrent refresh attempts, stores the rotated tokens, and retries the original request once. - Refresh failure or another unauthorized response clears tokens and all user-scoped query data, then redirects to
/login. - Explicit logout calls the backend first and always performs local teardown when the request settles.
The API client also provides:
- JSON, multipart,
GET,POST,PATCH,PUT, andDELETEhelpers; - a 30-second default request timeout;
- caller-provided
AbortSignalsupport; - typed
ApiErrorvalues for HTTP, network, timeout, abort, and invalid-JSON failures; and - streamed SSE parsing with an idle watchdog for grounded answers.
| Route | Purpose |
|---|---|
/ |
Public Folio overview and session-aware entry action |
/login |
Sign in |
/sign-up |
Create an account |
/spaces |
Search, sort, create, edit, and delete research spaces |
/spaces/:id |
Redirect to the space's Sources tab |
/spaces/:id/sources |
Browse, filter, add, edit, retry, and delete sources |
/spaces/:id/sources/:sourceId |
Read normalized source content and cited passages |
/spaces/:id/ask |
Browse and manage conversation history |
/spaces/:id/ask/new |
Start a space- or source-scoped conversation |
/spaces/:id/ask/:conversationId |
Replay and continue a conversation |
/spaces/:id/notes |
Browse, create, edit, view, convert, and delete notes |
/spaces/:id/notebook |
Edit and export the space notebook |
/ask/new deliberately shares the same dynamic route segment as persisted conversations. Once the opening stream event returns a real conversation ID, the URL can change without unmounting the component that owns the active stream.
All paths below are relative to API_BASE_URL, which defaults to /api/v1.
| Domain | Methods and paths used by this frontend |
|---|---|
| Authentication | POST /auth/login, POST /auth/sign-up, POST /auth/refresh, POST /auth/logout, GET /users/me |
| Spaces | GET/POST /spaces, GET/PATCH/DELETE /spaces/:id |
| Sources | GET/POST /sources, GET/PATCH/DELETE /sources/:id, POST /sources/:id/retry, GET /sources/:id/preview, GET /sources/status (SSE) |
| Passages | GET /passages/:id |
| Ask | POST /spaces/:id/ask (SSE), GET /spaces/:id/ask/suggestions |
| Conversations | GET /spaces/:id/conversations, GET/PATCH/DELETE /spaces/:id/conversations/:conversationId |
| Answer feedback | POST /spaces/:id/conversations/:conversationId/messages/:messageId/feedback |
| Notes | GET/POST /spaces/:id/notes, GET/PATCH/DELETE /spaces/:id/notes/:noteId, POST /spaces/:id/notes/:noteId/convert-to-source |
| Notebook | GET/PUT /spaces/:id/notebook |
Two backend URLs are reached without the API client, because a new tab or an <img> cannot carry a bearer header:
- Open original.
GET /sources/:id/previewreturns a relative/api/v1/sources/:id/file?token=…link. The token is a 5-minute signed token scoped to that one source. The reader resolves the link against the page origin and opens it in a new tab. - Reader images. Images extracted from DOCX and EPUB sources load straight from
/api/v1/sources/:id/media/:fileName.
Run commands from the repository root.
| Command | Purpose |
|---|---|
yarn dev |
Start the Next.js development server on port 3000 |
yarn build |
Create the optimized standalone production build |
yarn start |
Serve an existing production build |
yarn lint |
Run ESLint, Next.js rules, Prettier checks, and unused-import checks |
The repository currently has no automated test suite or checked-in CI workflow. Before opening a pull request, run:
yarn lint
yarn buildBoth commands must finish without errors. ESLint also reports Prettier and unused-variable warnings, so review warnings rather than treating a zero exit code as the only quality signal.
- Keep
page.tsxfiles as Server Components. Move state, event handlers, effects, and browser APIs into sibling Client Components. - Import project modules through the
@/*alias. - Keep domain API calls in
*.service.ts; components should consume the higher-leveluse*.tshooks. - Define stable query keys as
as consttuples and invalidate related data after successful mutations. - Use React Hook Form with module-level Zod schemas and the shared form components under
components/shared/ui/. - Use Sonner for mutation success and error feedback.
- Define design tokens in
app/globals.css; Tailwind CSS 4 is configured CSS-first and has notailwind.config.ts. - Prefer Tailwind's numeric spacing scale to arbitrary pixel lengths.
- Use the bundled Next.js 16 documentation under
node_modules/next/dist/docs/before changing framework-sensitive routing, caching, or server/client behavior. - Make focused changes and avoid refactoring unrelated code.
See AGENTS.md for the complete development guide.
Folio runs at https://folio.nustechnology.com on the shared NUS Technology VPS. This repo is its own Compose project, folio-web, with one service, folio-frontend. The backend is a separate project from Folio-BE; the two meet only on the external shared-network.
Browser ──https──► Cloudflare TLS terminates here; an Origin Rule
│ sends the subdomain to VPS port 3000
▼
shared-nginx :3000
├── / → folio-frontend:3000 (this repo)
├── /api/ → folio-backend:3001 (Folio-BE)
└── /api-docs → folio-backend:3001
Nginx serves the UI and the API from one hostname, so the default relative /api/v1 base works with no build args and no deployment hostname in the client bundle. A domain change needs no frontend rebuild. The token cookies are Secure in production, which works because Cloudflare serves the site over HTTPS.
The clone lives at /home/nus/srv/folio/Folio-Web. Its .env holds one line, COMPOSE_FILE=docker-compose.prod.yml, so bare Compose commands select the production stack:
cd /home/nus/srv/folio/Folio-Web
git pull
docker compose up -d --build
docker compose ps
docker compose logs -f folio-frontendA frontend deploy never restarts the API, the worker or the databases. docker-compose.prod.yml has no backend dependency, because browser API traffic reaches the backend through Nginx, not through frontend-container DNS. For the few seconds before the API is up, /api/ returns 502.
Outside the VPS, pass the file explicitly: docker compose -f docker-compose.prod.yml up -d --build.
The production image is built in three stages. The first two share a base stage: Node.js 22 Alpine with Yarn 4.12.0 activated through Corepack. The runner starts from plain node:22-alpine, because the standalone server never calls Yarn.
depsinstalls the immutable Yarn dependency tree.buildercreates the Next.js standalone output.runnercopies only the traced server, static assets, and public files into a non-root Node.js 22 Alpine image.
The container runs as uid 1001, listens on port 3000, publishes no host port, and includes an HTTP health check.
Deployment secrets and server access: contact SamHT.
Confirm that the backend is running on http://localhost:3001. If it uses another origin, set API_PROXY_TARGET in .env.local and restart the development server.
Remove a stale NEXT_PUBLIC_API_URL from .env.local. The normal local and production topology uses the relative /api/v1 base.
ngrok and Cloudflare tunnel domains are preconfigured. Add any other host to ALLOWED_DEV_ORIGINS, then restart yarn dev.
Add the frontend tunnel origin to the backend's development CORS allowlist. The Next.js rewrite changes the destination host but preserves the browser's Origin header.
Inspect the responses from /auth/refresh and /users/me. Refresh requires a valid refresh-token cookie, and the frontend only retries a request automatically when the backend identifies the 401 as TOKEN_EXPIRED.
Check the authenticated SSE request to /api/v1/sources/status. A valid access token is required; the client reconnects with exponential backoff and refreshes once after a 401.
Check the POST /api/v1/spaces/:id/ask response and backend logs. The client aborts a stream that produces no SSE frame for 60 seconds and surfaces the failure on the assistant message.
This project is licensed under the NUS Technology Non-Commercial License 1.0. Commercial use and redistribution are not permitted. For commercial licensing, contact NUS Technology.