Building a Full SaaS Application with the Claude API

Developing a production-grade Software-as-a-Service (SaaS) application with AI capabilities requires careful planning across the stack. In this comprehensive guide, we’ll walk through how to build a full end-to-end SaaS app leveraging Anthropic’s Claude API – an advanced large language model known for its long context window and safety features. We’ll focus on a practical use case: an AI-powered writing and document analysis platform (a knowledge assistant) that can ingest large documents and assist users via chat-based interactions. This example will illustrate architecture, workflows, and real-world implementation details applicable to many AI SaaS products.

Why Claude? Anthropic’s Claude is a state-of-the-art AI assistant built with “Constitutional AI” principles for safer, more reliable responses. Claude can handle very large inputs (up to 100k–200k tokens) in a single conversation while maintaining context. This makes it ideal for tasks like analyzing lengthy documents, multi-turn knowledge assistants, or content generation tools that need to reference long histories. Claude is also designed to minimize harmful or biased outputs, which is important for a production SaaS handling user content. By integrating Claude via its API, our SaaS can offer powerful AI features – from summarizing documents and answering questions to helping users draft and edit text – all within a robust, scalable web application.

Use Case: AI Writing & Document Analysis Platform

To make things concrete, imagine a SaaS product that acts as a knowledge assistant and writing aide. Users can upload or input lengthy documents (reports, research, articles) and then interact with an AI assistant (Claude) to do things like:

  • Document Q&A: Ask questions about an uploaded document or knowledge base and get detailed answers with references.
  • Summarization: Generate summaries of long texts or extract key points.
  • Content Generation & Editing: Get AI help to draft new content or rewrite/edit sections of a document.
  • Chat-based Assistance: Have a conversational agent that remembers context from the document and prior messages, enabling a natural chat experience for brainstorming or analysis.

This blend of an AI writing tool and a document analysis platform showcases Claude’s strengths. With its large context window, Claude can ingest a full document (potentially tens of thousands of words) at once and still have room for conversation. For example, Claude 3 can accept up to 200,000 tokens in context, which means the AI can consider an entire document (or multiple documents) plus the user’s queries all at once. This is a game-changer for building a knowledge assistant that doesn’t require complex chunking or external vector databases for many use cases – the AI can directly “read” the whole document.

Key features and requirements for our SaaS:

User Accounts: Users must be able to sign up, log in, and manage their sessions securely. We’ll support standard email/password auth and consider social logins (OAuth) for convenience.

Document Upload & Storage: Users can upload documents (e.g. PDFs or text). The system will extract text (perhaps using a Python service if needed for PDF parsing) and store documents in a database or object storage. (For simplicity, our focus will be on handling text content rather than binary files.)

AI Query Interface: A chat-like interface where users can ask questions or request actions (like “Summarize section 2 of the document” or “Improve the phrasing of this paragraph”). The frontend will send these requests to the backend, which will call the Claude API and return the results.

Long-Context Handling: We will send as much relevant context as needed in each Claude API call. For instance, to answer a question about a document, the backend will include the document’s text (or relevant portion) at the top of the prompt, followed by the user’s query. Claude’s long-context best practices suggest placing large content at the beginning of the prompt to improve response quality. We may also include conversation history messages to maintain the dialogue state.

Usage Tracking: Each Claude API call consumes tokens (input and output). We need to log usage per user – not only because Anthropic’s API has its own rate limits and costs, but also to implement fair-use limits and billing for our SaaS. Our system will track how many tokens each user consumes (perhaps per month).

Billing and Limits: We’ll implement a usage-based billing model using Stripe. For example, we might offer a free tier with a certain token quota and paid plans for higher usage. Stripe’s metered billing will allow us to charge customers based on actual usage. We also enforce hard limits (e.g. stop processing if a user exhausts their quota to avoid runaway costs) and soft limits (warn the user or throttle if they are approaching their limit).

Production-Grade Qualities: The app should be built with security, scalability, and maintainability in mind. This includes:

Proper authentication & authorization (no leaking of data between users).

Secure storage of API keys and user data (we must never expose the Claude API key on the client side).

Handling errors and exceptions gracefully – e.g. if the Claude API is down or returns an error, inform the user and possibly retry with backoff.

Rate limiting to prevent abuse or accidental flooding of our own API and the Claude API.

Logging and monitoring of system performance and Claude’s responses (to detect any issues or misuse).

Multi-tenant architecture considerations: even if we start with a single-tenant (all users in one app), we plan for the ability to isolate data per organization or deploy on separate subdomains for different customers if needed.

By clarifying these requirements, we ensure that our technology choices and implementation support the end goals. Now, let’s discuss the stack that will make it happen.

Technology Stack for an AI SaaS

Building a full-stack SaaS requires picking a stack that is robust, scalable, and familiar to developers. We’ll use a modern, widely-adopted stack that many SaaS companies use in production:

Backend: Node.js with Express (or Fastify) as the primary backend framework. Node is a natural choice for web services and integrates well with frontend tooling. We’ll use Node to implement our API endpoints (including those that call the Claude API) and handle authentication, business logic, and integration with the database and external services. Anthropic provides an official Node.js SDK for Claude which we will use on the backend to simplify API calls and streaming. Additionally, we might include a Python microservice for specialized tasks (optional). For example, if we need to perform heavy document parsing (like converting PDFs to text) or use certain AI libraries, a small Python service (using FastAPI or Flask) can be introduced. This service would communicate with the Node backend (over REST or gRPC) for tasks that Python is well-suited for. However, core API orchestration will reside in Node.js for a unified backend.

Frontend: React with Next.js. Next.js (currently v13/14 with the App Router) will serve our web application’s frontend. Next.js is a popular choice for SaaS because it supports hybrid rendering (Server-Side Rendering and static generation for marketing pages) and has built-in API routes and edge functions for backend logic. We’ll use Next.js primarily for the UI: pages for login, document upload, and the chat interface. We’ll also leverage Next.js API routes or serverless functions for certain tasks closely tied to the front-end (for example, Stripe webhooks or NextAuth authentication callbacks), while heavier logic resides in the Express backend. This approach gives us flexibility: in many cases, Next.js can handle a lot of back-end needs out of the box, but we use a separate Node server for the Claude-specific processing for clarity and scalability. Our UI will be built with modern components, likely using a utility-first CSS framework like Tailwind CSS for rapid, responsive styling (as it’s widely used in SaaS frontends). In fact, the Next.js starter will be initialized with Tailwind for convenience.

Database: PostgreSQL as our primary database. Postgres is a reliable, SQL database that is commonly used in SaaS applications for its robustness and features (transactions, JSON support, indexing, etc.). We’ll use it to store users, documents, chat history (if needed), and usage logs. To interact with Postgres in our Node backend, we’ll use Prisma ORM (or an alternative like Sequelize). Prisma is a type-safe ORM that integrates well with Node/TypeScript and Next.js, and it will allow us to define our schema models and perform migrations easily. For example, we’ll define models for User, Document, and UsageLog in the Prisma schema and let it generate a client we can use in code. Using an ORM also helps prevent SQL injection and simplifies database access. (If we needed full-text search or vector search for document content, we might also integrate something like Postgres full-text search or an external search service, but with Claude’s long context, we might not need a separate vector DB initially.)

Authentication: We have two main approaches – NextAuth.js or JSON Web Tokens (JWT). NextAuth is a popular authentication library tailored for Next.js, providing an easy way to integrate providers like Google or GitHub login, as well as email/password, and managing sessions for us. It can save time by handling OAuth flows and session cookies securely. We’ll likely use NextAuth in our Next.js app for a seamless login experience (since our frontend is Next). The NextAuth setup would run on Next.js API routes (e.g. pages/api/auth/[...nextauth].js), and we can configure it with credential login and OAuth providers. Under the hood, NextAuth can either use secure, HTTP-only cookies or JWTs for session tokens. In a production SaaS, either approach is fine, but using stateless JWTs for sessions has the benefit of easy horizontal scaling (no sticky sessions needed) and the ability to have the Node backend independently verify user identity. We will demonstrate a JWT-based flow for clarity: the user logs in (via NextAuth or a custom endpoint) and obtains a token, and that token is sent with subsequent requests to authorize them on the Node API.

Claude API Integration: The star of our app is the Claude AI model accessed via Anthropic’s API. The Claude API is a cloud-based RESTful service at https://api.anthropic.com. We will integrate it on the server side only – this is crucial for security. The API requires an API key and certain headers; we will never expose the API key on the client. Instead, the Node backend will hold the key as an environment variable and include it in requests to Claude. Anthropic offers official SDKs, including a Node/TypeScript SDK that handles the required headers, streaming, and error handling for us. We’ll use this SDK (@anthropic-ai/sdk on npm) in our backend code to call Claude’s v1/messages endpoint for conversational queries. The integration will cover:

Sending the appropriate prompt format (a list of {role, content} messages, similar to OpenAI’s style) and parameters like model name, max_tokens, etc.

Handling streaming responses. Claude supports streaming output, which means we can start sending partial results to the user as Claude generates them. We’ll implement streaming in our backend route and propagate it to the frontend (e.g. using Server-Sent Events or websockets) for a real-time experience.

Respecting rate limits and managing costs. The Claude API has rate limits per organization and model (e.g. limits on requests per minute and tokens per minute). We must design our app to avoid exceeding these. This involves implementing rate limiting on our own API (per user or globally) and possibly using Anthropic’s token counting API to estimate token usage before sending large prompts. We will also consider retry logic with exponential backoff for transient failures or 429 rate limit errors.

Model selection: Claude comes in different versions (e.g. Claude Instant vs Claude 2, or “Claude 3.5 Sonnet” vs “Claude 3.5 Opus/Haiku”) with varying capabilities and costs. For our use case, we might choose a balanced model like Claude “Sonnet” (which offers strong performance and a large context at moderate cost). The model name will be specified in the API calls. We might even allow configuring which model to use per request for flexibility.

UI Component Library: We will use Tailwind CSS for styling (as mentioned) and could use a component library like Material UI or Headless UI to speed up building common components (buttons, modals, etc.). Tailwind gives us utility classes to quickly style our React components (for example, we can make a chat bubble with a few div and Tailwind classes instead of writing CSS from scratch). This is a common approach in modern SaaS frontends.

Payment Processing:Stripe will be used for handling payments and subscriptions. Stripe is the de-facto standard for SaaS billing due to its powerful API and support for various billing models. We will integrate Stripe on the backend to:

Collect payment details and subscribe users to a plan (e.g. using Stripe Checkout sessions or the Stripe customer portal).

If using usage-based billing, create a Stripe metered subscription where we regularly report usage (tokens) and Stripe charges accordingly.

Handle webhooks from Stripe to update our system (e.g. when a payment succeeds, subscription status changes, etc.).

Ensure that billing-related secrets (Stripe secret key, webhook secret) are stored as environment variables and not exposed publicly.

Deployment Platform: For deployment, we’ll use a combination of services:

Vercel for deploying the Next.js frontend (and its serverless API routes). Vercel is highly optimized for Next.js apps and can globally CDN-cache pages, handle edge functions, etc. It will allow our frontend to scale effortlessly to many users and also run light API endpoints (like authentication callbacks or webhooks) serverlessly.

AWS for deploying the Node.js backend and database. For example, we can host the Node API on AWS in multiple ways: as a container on AWS Elastic Container Service (ECS) behind an Application Load Balancer, or as serverless functions using AWS Lambda + API Gateway for each route (if we break the backend into lambdas). Another approach is using AWS Elastic Beanstalk or a similar PaaS to host the Node server. We will discuss a serverless approach (API Gateway + Lambda) for fine-grained scaling. The PostgreSQL database can be hosted on Amazon RDS (managed Postgres) or a cloud DB provider like Supabase or Neon. Using a managed DB ensures automated backups, scaling, and reliability.

Docker will be used to containerize our application for portability. We will create Docker images for the Node backend (and the Python service if we have one) so that we can run them locally and deploy consistently. Containerization is a best practice for production services; it also makes scaling on Kubernetes or other platforms easier later.

We will pay attention to environment configuration and secrets management in deployment. All sensitive keys (Claude API key, DB credentials, Stripe keys, JWT secret, etc.) will be injected via environment variables. In production, we might use a secrets manager (like AWS Secrets Manager or HashiCorp Vault) to store these and load them into the environment at runtime. This aligns with 12-factor app principles for config. We never hard-code secrets in our code or repository.

This tech stack reflects what many real SaaS startups use: Next.js + Node + Postgres + Stripe covers everything from the UI to data to monetization. With these choices made, let’s design the high-level architecture before drilling into code.

Architecture Design and Workflow

A clear architecture is critical for a large application. We’ll use a modular, scalable architecture where the front-end and back-end are decoupled but communicate via well-defined APIs. Below is an overview of the system’s components and how data flows between them:

Frontend (Next.js + React): This is the client-facing application running in the user’s browser. It consists of pages for different features:

A landing page and marketing pages (could be statically generated).

A registration/login page (or modal) for authentication.

The main app interface (once logged in): e.g. a dashboard where the user can upload or select a document, and a chat interface/editor where the user converses with the AI or receives writing suggestions.

The frontend is responsible for capturing user input (queries, document uploads, etc.), displaying results, and providing a smooth UX (loading spinners, real-time updates, etc.).

When the user triggers an action that requires backend processing (like asking a question to Claude or saving a document), the frontend will make an HTTP request to the backend API. These requests include the user’s auth token (so the backend knows who is making the request). We’ll mostly use JSON over REST (HTTPS) for these API calls.

Backend API (Node.js Express server): This is the heart of our application logic. The backend exposes RESTful endpoints (or GraphQL, but we’ll assume REST for simplicity) that the frontend can call. Key backend modules:

Authentication & User Management: Endpoints for logging in, signing up, and possibly managing profiles. If using NextAuth, some of this is handled by Next.js, but our backend may still need to verify JWTs or handle password resets, etc. For example, we might have a POST /api/login for email/password which returns a JWT, and a middleware that verifies JWTs on protected routes.

Document Management: Endpoints to upload documents, fetch a user’s documents, or delete them. For instance, POST /api/documents (to upload a new doc), GET /api/documents (list all user docs), GET /api/documents/{id} (retrieve one, perhaps for viewing or sending to Claude). The actual file content might be stored in the database or an external storage like S3. (For large files, storing in S3 and only storing references in Postgres is ideal, but in our case if we limit to text content, storing in Postgres is fine).

Claude AI Proxy: This is crucial. We will have an endpoint such as POST /api/assistant/query (or /api/claude) that the frontend calls whenever the user sends a prompt to the AI. This endpoint will:Check the user’s authentication (and maybe their plan/usage). Read the request body which includes the user’s message and context (e.g. which document to reference or the conversation history). Construct a prompt for Claude. This might involve fetching the document text from the DB if a doc is referenced, and then creating the messages array for the API call. For example, we might prepend a system message like “You are a helpful assistant for analyzing the user’s documents” and then a message containing the document content (if not too large) followed by the user’s question as the last message. Call the Claude API using the Anthropic SDK with the prepared prompt. This could be done synchronously (wait for full response) or using streaming.

We’ll implement both modes: for quick answers, non-stream is fine; for long answers or just better UX, streaming is preferred. Receive Claude’s response and process it. We might do some post-processing (e.g. if we asked Claude to quote sources, we might format those).Log the usage from Claude’s response: the SDK or API returns the number of tokens used in the response.usage field. We’ll save that to the database (e.g. in a UsageLog table with user, timestamp, input_tokens, output_tokens).Send the response back to the client. If streaming, we’ll stream it chunk by chunk to the frontend.

Usage & Billing: Endpoints to get the user’s usage statistics and maybe current billing info. For example, GET /api/usage could return how many tokens the user used this month and their limit, which the frontend can display in a dashboard. We might also have an endpoint to create a Stripe Checkout session (POST /api/billing/checkout) if the user wants to upgrade or purchase more credits. Webhook endpoints (e.g. POST /api/billing/webhook) will handle incoming Stripe webhooks for subscription events.

Administrative/Monitoring: Possibly some internal endpoints or tools for monitoring system health (e.g. a /api/health endpoint for health checks) and for admins to manage or review usage (not user-facing). The Node backend will be stateless (no in-memory user sessions) – it will trust JWTs or session tokens for auth, so it’s horizontally scalable. If we need to store session data (for example, NextAuth’s default sessions or rate-limit counters), we will use a shared store like Redis instead of local memory.

Anthropic Claude API (external service): The backend communicates with Claude through HTTP calls to Anthropic’s servers. Every call includes our API key in headers and the model name and prompt in the JSON body. The Claude service processes the prompt and returns a completion. Because this is an external dependency with usage costs and rate limits, the backend must handle failures gracefully (retry or report error) and not overload with too many concurrent requests. We may also implement caching: if the same user asks the same question on the same document repeatedly, we could cache the answer for a short time to save tokens.

PostgreSQL Database: The central data store. We’ll likely have tables (or Prisma models) such as:User – stores user info (id, email, hashed password or OAuth identity, plan type, Stripe customer ID, etc.).

Document – stores documents uploaded by users (id, user_id, title, content or URL to content, possibly tokens count of the content, etc.). Could also store metadata like upload date. Conversation (optional) – if we want to store chat history separately (each conversation could be tied to a document or just general). This could help users refer back to previous Q&A or for us to feed the conversation context to Claude. Alternatively, we don’t persist conversation beyond what’s needed for the current session.

UsageLog – records of Claude API usage: user_id, timestamp, input_tokens, output_tokens, model, maybe a reference to document or conversation. This will be used for billing and for possibly showing the user a history of their queries.

Subscription or Billing – stores billing info like the user’s plan, next billing date, usage this period, etc. We might not need a complex billing table if we rely on Stripe for most of that, but having a record of plan and status in our DB can be useful for quick checks and for allowing/denying service based on status. For usage-based billing, we may store a running total of tokens used in the current period here for quick comparison against limits.We will ensure multi-tenancy by scoping every data record to a user or an organization.

In this simple design, each Document and UsageLog has a user_id foreign key – users can only access their own records (enforced both in application logic and ideally at query level). For SaaS serving multiple business clients, one might introduce an Organization model and have user.organization_id and data tied to org, but we’ll stick to user-level isolation for now. Multi-tenancy principle: one application instance serves many customers, but each customer’s data is isolated by design. In high-end SaaS, sometimes a database-per-tenant approach is used for maximum isolation, but that adds complexity. We’ll assume a single shared database with proper filtering (which is typical for most SaaS until they reach significant scale).

Stripe & External Services: The backend interacts with Stripe for billing events (creating checkout sessions, receiving webhooks). The frontend might also include Stripe’s JS (e.g. to display checkout or payment forms), but sensitive operations and secrets remain in the backend. We might use other external services too, like an email service for sending welcome emails or password resets, but those are ancillary (not our focus here).

Putting it together, here’s a simplified architecture diagram (in text form):

[Frontend: Next.js/React]  <--HTTPS-->  [Backend: Node.js Express API]  <--->  [PostgreSQL Database]
         |                                    |---> (calls) Claude API (Anthropic cloud)
         |                                    |---> (calls) Stripe API (payments)
         |                                    \---> (calls, optional) Python Service (for heavy tasks)
         \-- Web UI --> Users interact        |<---returns Claude responses

And here’s the end-to-end flow of a typical request in our system:

User action (Frontend): A logged-in user goes to their document analysis page on the frontend. They have already uploaded a document, and now they type a question: “What are the main findings in this report?” and hit “Ask Claude”.

Frontend API call: The React component makes a request to the backend, e.g. an AJAX call to POST /api/assistant/query. This request includes the user’s auth token (automatically if using cookies, or an Authorization header if using JWT) and the payload: the question and perhaps an identifier for the document to analyze.

Backend receives request: The Express API has a route handler for /api/assistant/query. Middleware on this route verifies the user’s authentication token (for example, decoding the JWT to get user_id, or checking the session cookie via NextAuth). Once authorized, the handler proceeds.

Preparing Claude prompt: The backend loads the relevant context. If a document ID was provided, it queries the DB for that document’s content (ensuring that document’s user_id matches the requesting user). Let’s say it retrieves a 20,000-token text content. The backend then constructs the prompt messages array for Claude. For instance:

System message: "You are an AI assistant that helps answer questions about user-provided documents." (We might include instructions to cite parts of the document if needed, or other behavior constraints).

Assistant (or system) message with document content: We might prepend something like "[Document]\n<the full document text>\n[/Document]" or use the <document> tag structure recommended by Anthropic for multiple documents. The key is this long content is placed before the user’s question to maximize relevance.

User message: containing the actual question, e.g. "Question: What are the main findings in this report?".
Claude will then read the document text (which we put at the top) and see the user’s question at the end, which is an effective strategy for long prompts.

Calling Claude API: The backend now uses the Anthropic SDK to send this prompt. For example, using the Node SDK:

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const completion = await anthropic.messages.create({
    model: "claude-3-5-sonnet",  // chosen model
    messages: promptMessages,
    max_tokens: 1000,            // limit on output tokens
    temperature: 0.7,            // some creativity
    stream: true                 // let's use streaming for responsiveness
});

The SDK call above will return an async iterator if stream: true is set. Our code can iterate over chunks as Claude generates text. Each chunk might contain a piece of the answer (chunk.delta.text) which we accumulate or directly send to the client in real time. If we weren’t streaming, we’d just get a full response object after the call completes with completion.content containing the final answer text.

Streaming response to client: As we receive chunks from Claude, our backend writes them to the HTTP response. We set up the endpoint to use Server-Sent Events (SSE) or a similar mechanism so that the client can receive data incrementally. For example, in Express we can set Content-Type: text/event-stream and flush chunks:

res.setHeader('Content-Type', 'text/event-stream');
for await (const chunk of completion) {
    if (chunk.type === 'content_block_delta') {
        res.write(`data: ${chunk.delta.text}\n\n`);
    }
}
res.write('data: [DONE]\n\n');
res.end();

This way, the user’s browser starts receiving Claude’s answer as it’s being generated (the UI could show a loading ellipsis and each new sentence as it arrives). Streaming greatly improves UX for long answers, as the user sees progress instead of waiting many seconds with no output.

Logging usage: Once the response is done (or even during, for partial data), the backend logs the usage. The Claude API response (final or chunks) includes a usage summary with input_tokens and output_tokens used. Suppose the prompt was 20,100 tokens and Claude’s answer was 500 tokens; we record that the user used 20,600 tokens for this request. In code, after the loop we might do:

const usageInfo = completion.response?.usage;  // (exact access depends on SDK)
await prisma.usageLog.create({
    data: {
       userId: user.id,
       inputTokens: usageInfo?.input_tokens || 0,
       outputTokens: usageInfo?.output_tokens || 0,
       model: completion.response?.model || "claude-3-5-sonnet",
       timestamp: new Date()
    }
});

We also update any in-memory counters for rate limiting. For example, we might have a per-user counter to ensure they don’t exceed, say, 5 requests per minute or 100k tokens per hour. If a limit would be exceeded, we could have paused before the Claude call (queuing the request or rejecting it with a 429 Too Many Requests).

Frontend receives answer: On the client side, if using SSE, we have code to handle the stream. For instance, a simple implementation:

const evtSource = new EventSource('/api/assistant/query?docId=123');
let answerText = "";
evtSource.onmessage = (event) => {
    if (event.data === "[DONE]") {
        evtSource.close();
        setAnswer(answerText); // finalize the answer in state
    } else {
        answerText += event.data;
        setAnswer(answerText); // update partial answer in UI state
    }
};

This would continuously update the answer state, and our React component renders the answer as it grows. The user can watch Claude “typing” the answer. In case we didn’t implement streaming, the client would just await the fetch response and then display the answer all at once. Either way, the user now sees: “Answer: The main findings of the report are …”.

Follow-up interactions: The user can ask another question or say “Can you elaborate on point 2?” and the cycle repeats. Our frontend will include the conversation history (previous Q&A) in the next request’s payload, and the backend will prepend those messages in the Claude prompt to maintain context. Because Claude’s API is stateless (it doesn’t remember past conversations unless you resend them), our system needs to manage context by resending relevant history each time. We might limit how much history to include based on token limits or just keep the last few interactions if the context size is huge.

This flow demonstrates how the pieces work together. Importantly, all sensitive operations happen on the backend: the frontend never directly contacts Claude or Stripe or the database; it goes through our controlled API. This allows us to enforce security (checking auth, input sizes, etc.) and encapsulate the complexity.

Next, let’s dive deeper into implementing the backend and frontend, with code examples and best practices for each part.

Backend Implementation (Node.js, Express, Prisma)

Our backend will be a Node.js application, which we can initialize with Express (a minimal web framework). We will structure it with routes/controllers for different domains (auth, documents, assistant, billing). For brevity, the code examples will be simplified, but in a real app you might break them into separate files and use middleware for things like authentication.

Setting Up the Express Server

First, initialize a Node project (with TypeScript if desired) and install needed dependencies:

npm init -y
npm install express cors dotenv @anthropic-ai/sdk prisma @prisma/client jsonwebtoken bcrypt
  • express – web framework for routes.
  • cors – to handle cross-origin requests (if our frontend is served from a different domain than the backend, we’ll enable CORS for that origin).
  • dotenv – to load environment variables from a .env file (for local development).
  • @anthropic-ai/sdk – official SDK to call Claude API.
  • prisma and @prisma/client – for database ORM (after setting up Prisma schema, we’ll generate the client).
  • jsonwebtoken – for signing/verifying JWT tokens in custom auth.
  • bcrypt – for hashing passwords.

We also ensure we have Postgres set up and a connection string in our env (e.g. DATABASE_URL).

Express basic setup (server.js or index.js):

const express = require('express');
const cors = require('cors');
require('dotenv').config();

const app = express();
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json());

// Health check endpoint
app.get('/api/health', (req, res) => {
  res.status(200).json({ status: 'OK', time: new Date() });
});

// ... (routes will be added here)

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  console.log(`Backend server listening on port ${PORT}`);
});

We enabled CORS for process.env.FRONTEND_URL (e.g. https://myapp.vercel.app) to allow our Next.js frontend to call this API. We also parse JSON request bodies.

Database Schema with Prisma

Before writing route logic, define the database models in schema.prisma:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
generator client {
  provider = "prisma-client-js"
}

model User {
  id            String   @id @default(uuid())
  email         String   @unique
  passwordHash  String?
  name          String?
  // NextAuth or OAuth providers can add more fields for provider ids
  stripeCustomerId String?
  plan           String   @default("FREE") // e.g., FREE, PRO, etc.
  usageLogs     UsageLog[]
  documents     Document[]
  // timestamps
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
}

model Document {
  id         Int      @id @default(autoincrement())
  user       User     @relation(fields: [userId], references: [id])
  userId     String
  title      String
  content    String   // store text content (if large, consider Text type or moving to S3)
  tokens     Int?     // token count of content, for reference
  createdAt  DateTime @default(now())
}

model UsageLog {
  id          Int      @id @default(autoincrement())
  user        User     @relation(fields: [userId], references: [id])
  userId      String
  model       String
  inputTokens Int
  outputTokens Int
  createdAt   DateTime @default(now())
}

This is a simple schema. In a real app, you might also have a Conversation model to track multi-turn chats, and a Subscription model for billing info (or extend the User model with billing fields). But with UsageLog and the User’s plan, we have enough to enforce limits and do billing.

Run npx prisma migrate dev --name init to create the database tables, and npx prisma generate to generate the client. We can then use const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); in our app to query the DB.

Authentication: Sign Up and Login (with JWT)

We’ll implement a simple email/password auth to illustrate (even if in production one might use NextAuth for easier social logins). We need routes for:

  • Register: hash the password and save new user.
  • Login: verify credentials and issue JWT.

Password hashing: Use bcrypt to hash passwords before storing. For security, use a strong salt (bcrypt does this internally). Never store plaintext passwords.

JWT generation: We’ll use jsonwebtoken. We need a secret key (set JWT_SECRET in .env). We will sign the token with the user’s ID and maybe an expiration.

Add the auth routes in Express:

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

// Register route
app.post('/api/register', async (req, res) => {
  const { email, password, name } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: "Email and password required" });
  }
  try {
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) {
      return res.status(409).json({ error: "Email already registered" });
    }
    const passwordHash = await bcrypt.hash(password, 10);
    const user = await prisma.user.create({
      data: { email, passwordHash, name }
    });
    // Optionally, create a Stripe customer for this user and save stripeCustomerId
    return res.status(201).json({ message: "User registered, please login" });
  } catch (err) {
    console.error("Register error:", err);
    res.status(500).json({ error: "Internal error" });
  }
});

// Login route
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: "Email and password required" });
  }
  try {
    const user = await prisma.user.findUnique({ where: { email } });
    if (!user || !user.passwordHash) {
      return res.status(401).json({ error: "Invalid credentials" });
    }
    const valid = await bcrypt.compare(password, user.passwordHash);
    if (!valid) {
      return res.status(401).json({ error: "Invalid credentials" });
    }
    // Credentials are correct – create JWT
    const token = jwt.sign(
      { sub: user.id, email: user.email }, 
      process.env.JWT_SECRET, 
      { expiresIn: '1h' }
    );
    return res.json({ token });
  } catch (err) {
    console.error("Login error:", err);
    res.status(500).json({ error: "Internal error" });
  }
});

Now, after login, the frontend will receive a JWT (JSON Web Token). We can store this token in the browser (either in memory, or localStorage, or as a cookie). A secure approach is to store it in an HTTP-only cookie so it’s not accessible via JS (reducing XSS risk), but setting that cookie from a response might require some configuration (CORS and cookie settings). For simplicity, the above returns it in JSON; the frontend can then include it in an Authorization header for future requests.

Protecting routes: We should create an auth middleware that checks for a valid JWT on protected routes:

function authMiddleware(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];  // expecting "Bearer TOKEN"
  if (!token) {
    return res.sendStatus(401);
  }
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;  // attach user info to request
    next();
  } catch (err) {
    return res.sendStatus(403); // invalid token
  }
}

We would then use app.use('/api/assistant', authMiddleware) to protect the assistant routes, and similarly for document and usage endpoints. (If using NextAuth, Next.js provides a way to protect pages and fetch the session on API routes; but since we show custom JWT here, we manage it ourselves.)

Claude API Integration in Backend

Now the core: the route that interacts with Claude. Let’s implement a basic /api/assistant/query endpoint as discussed:

const { Anthropic, AI_PROMPT, HUMAN_PROMPT } = require('@anthropic-ai/sdk'); 
// The SDK might provide constants for prompt separators if needed (Anthropic's older API used special tokens like "\n\nHuman:"; with the new SDK we use messages instead.)

const anthropicClient = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

// Ensure to set ANTHROPIC_API_KEY and ANTHROPIC_VERSION (if required) in environment.

app.post('/api/assistant/query', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  const { documentId, messages, question } = req.body;
  // `messages` could be an array of past messages for context
  // `question` could be the new query if not included as last message

  try {
    // Fetch document content if provided
    let documentContent = "";
    if (documentId) {
      const doc = await prisma.document.findFirst({
        where: { id: documentId, userId: userId }
      });
      if (!doc) return res.status(404).json({ error: "Document not found" });
      documentContent = doc.content;
    }
    // Construct the prompt messages for Claude
    const promptMessages = [];
    if (documentContent) {
      promptMessages.push({ role: "system", content: `Here is a document from the user:\n${documentContent}` });
    }
    if (messages && messages.length > 0) {
      // include previous conversation turns if any (assuming they are objects {role, content})
      for (const m of messages) {
        promptMessages.push(m);
      }
    }
    // Finally, add the latest user question if provided separately
    if (question) {
      promptMessages.push({ role: "user", content: question });
    }

    // Call Claude (streaming)
    const completion = await anthropicClient.messages.create({
      model: "claude-2",  // or latest Claude model name
      messages: promptMessages,
      max_tokens: 1000,
      temperature: 0.7,
      stream: true
    });
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    // If we want SSE to work, ensure the response is flushed periodically:
    req.socket.setKeepAlive(true);
    req.socket.setTimeout(0);

    let responseText = "";
    for await (const chunk of completion) {
      if (chunk.type === 'content_block_delta') {
        const text = chunk.delta?.text || "";
        responseText += text;
        res.write(`data: ${text}\n\n`); // send chunk to client
      }
    }
    res.write(`data: [DONE]\n\n`);
    res.end();

    // After streaming is done, log usage (the SDK may not give usage in streaming mode until end)
    const usageInfo = completion.response?.usage;
    if (usageInfo) {
      await prisma.usageLog.create({
        data: {
          userId: userId,
          model: completion.response.model || "claude-2",
          inputTokens: usageInfo.input_tokens || 0,
          outputTokens: usageInfo.output_tokens || 0
        }
      });
    }
  } catch (error) {
    console.error("Claude API error:", error);
    // If error occurs mid-stream, we should notify client
    if (!res.headersSent) {
      res.status(500).json({ error: "Failed to process query" });
    } else {
      // If streaming already started, send an error indicator and end
      res.write(`data: [ERROR]\n\n`);
      res.end();
    }
  }
});

There’s a lot going on here:

  • We verify the user via authMiddleware.
  • We accept documentId, messages, and question from the request. The frontend might send documentId (the context doc to use) and the latest question, plus perhaps the last few messages for continuity.
  • We fetch the document content from the DB (ensuring it belongs to the user).
  • We build the promptMessages array. We chose to put the document content as a system message at the top. Alternatively, we might format it as a user message saying “Document: …” or use some delineation. The structure can be tweaked and even include instructions for Claude on how to use the document. The key is that it goes before the user’s question.
  • We append any prior conversation (the messages array might contain earlier Q&A pairs, which we include in order).
  • We then append the new user question as the final message with role “user”.
  • We call anthropicClient.messages.create with stream: true. We specified a model (e.g. "claude-2" for latest version, or a specific model like "claude-3-5-sonnet"). We set some parameters: max_tokens for the answer limit (we might adjust this based on user’s plan or request type), and temperature for randomness (0.7 is moderately creative; lower like 0 would be more deterministic).
  • We set up the response as an SSE stream. We write each chunk of text as data: ... lines. (In a real app, we should also handle events like the end of completion or keep-alive comments : ping\n\n, but we’ll keep it simple.)
  • We accumulate responseText in case we want to log or use it after streaming (not strictly necessary if we only stream out).
  • Once done, we send a [DONE] message and end the stream.
  • Then, importantly, we log the usage. The SDK completion.response might hold the final full response including usage. If not, we might need to separately call the token count API or infer from our prompt length. But assuming we get input_tokens and output_tokens, we save them.
  • We wrap everything in a try/catch to handle errors. If an error occurs after sending some chunks, we attempt to notify the client with an [ERROR] message. (The client could then show an error state.)

Rate limiting and usage enforcement: The above code does not include explicit checks for user’s usage quotas or rate limiting. In a real app, we should add:

  • A check at the top of the handler: fetch the user’s total tokens used this month from UsageLog and if it’s above their plan’s allowance, either reject (HTTP 402 Payment Required or 429) or truncate the request. This is a hard limit enforcement. For example, free plan users might have 100k tokens/month; if they exceeded it, we refuse further queries until next month or upgrade.
  • A rate-limit check: ensure the user is not sending too many requests too quickly. We could use an in-memory counter or Redis. A simple approach: in authMiddleware, after verifying JWT, also rate-limit by IP or userId. There are npm packages like express-rate-limit we could integrate, or implement a token bucket. For example, allow maybe 5 requests per minute per user. Given that each request can be heavy (Claude calls), that’s probably enough. If the limit is exceeded, respond with status 429. We can set response headers like X-RateLimit-Limit and X-RateLimit-Remaining for transparency. The code from the DEV guide shows an example of using an LRU cache for rate limiting within Next.js API routes. For our purposes, even a simple memory counter can do, but for robustness across multiple server instances, use Redis as mentioned (so all instances share the count).
  • For Anthropic API rate limits: If our app is small-scale, the per-user limits will likely be below Anthropic’s org-level limits. But if we have many users, we should monitor the aggregate calls. Anthropic uses a bucket system and auto-tiering. If we hit their limits, we’ll get 429 errors. We can handle those by implementing retries with backoff. Ideally, also design the app to queue or throttle calls if near the known limit (e.g., don’t let 100 users simultaneously each send a 100k token prompt, as that could hit the TPM limit).

Claude API best practices: We should highlight a couple of best practices when integrating Claude:

  • Model selection and versions: Keep the model ID configurable via environment or a setting, so you can switch to newer Claude versions when available (Anthropic often releases updates). Our code is currently hardcoding "claude-2"; better to have CLAUDE_MODEL=claude-2 in env and use that.
  • Token counting: Before sending a very large prompt, we could use the Claude API’s /v1/messages/count_tokens endpoint to get a token count. This helps avoid sending something that exceeds the model’s limit (though Claude 100k context is huge, if a user uploads a 300k token text, we can’t send it all). We might implement logic to truncate or summarize content if it’s too long. For example, if document is too large, perhaps prompt Claude to summarize it first, then answer questions on the summary – or use the new Claude Files API (in beta) that allows uploading a file and referencing it across requests.
  • Streaming vs non-streaming: For actions like “improve this text” where the output might not be huge, we could call without streaming and just get the result faster. But streaming is generally a better user experience for anything that takes more than a second or two.
  • Error handling: The Claude API might occasionally return errors or produce incomplete responses. We should implement at least basic error checks – e.g., if we receive an stop_reason in the response indicating something like length cutoff, we could inform the user that the answer was truncated. Or if Claude refuses (maybe content violation, though it’s rare with legitimate queries due to Constitutional AI), we handle that gracefully (perhaps telling the user we can’t fulfill that request).
  • Testing Claude integration: Use test questions and data to verify that the prompt format yields the desired outputs. Prompt engineering is key – we might need to adjust how we feed the document (maybe adding markers or instructions for Claude to not just regurgitate it but extract answers, etc.). For example, one might instruct: “Answer only based on the document and quote relevant passages. If the answer is not in the document, say you don’t know.” These instructions would go in a system message.

Document Management Endpoints

In our use case, an important piece is allowing users to upload and manage documents. We won’t write full code for file upload (which involves handling multi-part form data or using something like AWS S3 pre-signed uploads). Instead, assume we get the document text somehow:

  • If it’s a text file or PDF, the frontend could extract text client-side or send the file to an endpoint, which then uses a library or microservice to extract text.
  • For simplicity, let’s say the frontend sends the raw text content to an endpoint /api/documents to save.

Example route for creating a document:

app.post('/api/documents', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  const { title, content } = req.body;
  if (!content || !title) {
    return res.status(400).json({ error: "Title and content are required" });
  }
  try {
    // Optionally, enforce a size limit on content
    const tokenCount = /* function to count tokens in content */ content.split(/\s+/).length;
    // The above is naive; we could use Anthropic's count_tokens API for accurate count if needed.

    const doc = await prisma.document.create({
      data: { userId, title, content, tokens: tokenCount }
    });
    res.status(201).json({ documentId: doc.id });
  } catch (err) {
    console.error("Create document error:", err);
    res.status(500).json({ error: "Failed to save document" });
  }
});

And a GET endpoint to list documents or retrieve one:

app.get('/api/documents', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  const docs = await prisma.document.findMany({
    where: { userId },
    select: { id: true, title: true, createdAt: true, tokens: true }
  });
  res.json(docs);
});

app.get('/api/documents/:id', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  const docId = Number(req.params.id);
  const doc = await prisma.document.findFirst({ where: { id: docId, userId } });
  if (!doc) {
    return res.status(404).json({ error: "Not found" });
  }
  res.json({ title: doc.title, content: doc.content });
});

These allow the frontend to manage documents. We filter by userId so one user cannot fetch another’s documents (basic access control). If documents are large, consider not sending the full content in list API (only when needed for analysis).

In a more advanced scenario, we’d integrate a storage service:

  • Use AWS S3 or similar: upload the file to S3 directly from the client (with a signed URL), then trigger a backend function to process it (extract text) and save to DB. This offloads file handling from our Node server and is more scalable for big files.
  • But given the context window approach, maybe we only care about text extraction.

Usage Logging and Analytics

We already implemented logging each Claude API call in UsageLog. We might also want an endpoint to retrieve usage stats for the current user (so they can see their usage). For example:

app.get('/api/usage', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
  const usage = await prisma.usageLog.aggregate({
    _sum: { inputTokens: true, outputTokens: true },
    where: {
      userId,
      createdAt: { gte: startOfMonth }
    }
  });
  const totalInput = usage._sum.inputTokens || 0;
  const totalOutput = usage._sum.outputTokens || 0;
  const total = totalInput + totalOutput;
  // Determine plan limit (could fetch user.plan and have preset limits)
  const plan = await prisma.user.findUnique({ where: { id: userId }, select: { plan: true }});
  let limit = 0;
  if (plan?.plan === "FREE") limit = 100000; // e.g. 100k tokens
  if (plan?.plan === "PRO") limit = 1000000;  // e.g. 1M tokens
  res.json({ totalTokens: total, inputTokens: totalInput, outputTokens: totalOutput, limit });
});

This gives the client the usage in the current period. (We used the start of month as an example period boundary; could be a rolling 30-day window or strictly monthly cycles aligned with billing cycle.)

Billing with Stripe (Usage-Based Subscription)

Implementing billing thoroughly involves a few steps:

  • Creating a Stripe Customer for each user and perhaps attaching a payment method.
  • Setting up a Product and Pricing in Stripe for our usage metric (e.g. $X per 1,000 tokens or tiered plans).
  • Creating a Subscription for the user. If usage-based (metered billing), the subscription in Stripe will have a price with type=metered. We then regularly send usage records to Stripe.
  • Handling webhooks for events like payment succeeded, subscription canceled, etc., to update user’s plan or status in our DB.

Given our focus on usage-based, one approach is:

  • Define in Stripe a product “Token Usage” with a price “$10 per 100k tokens” (for example). This is a metered price.
  • When a user upgrades from free to paid, we create a subscription for them to that price. Stripe will start a billing cycle (say monthly).
  • Each time the user uses tokens, we call Stripe’s API to record usage. Stripe’s documentation says: During each billing period, you create usage records for each customer and Stripe adds them up to determine how much to bill for. We could do this asynchronously (maybe batch update once a day or after each request).
  • At period end, Stripe will invoice the user for the total usage.
  • For free plan users, we might not create any subscription (they are simply capped by our logic).
  • Alternatively, we could use Stripe’s Stripe Checkout for one-off purchases of credits (some SaaS prefer a prepaid model).
  • Simpler: Use tiered subscriptions (e.g., Free, Pro with a fixed token cap, etc.) and if they exceed cap, either restrict or automatically charge overages. But that can frustrate users if unexpected charges. Many opt to just not allow usage beyond plan unless upgraded.

For demonstration, let’s assume a subscription with metered billing:
We can have an endpoint to create a Stripe Checkout session for upgrading:

const Stripe = require('stripe');
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/api/billing/checkout', authMiddleware, async (req, res) => {
  const userId = req.user.sub;
  // Assume we have one paid plan (product/price) configured:
  const priceId = process.env.STRIPE_USAGE_PRICE_ID; // e.g. price_xxx from Stripe
  try {
    // Ensure Stripe customer exists for this user
    const user = await prisma.user.findUnique({ where: { id: userId }});
    let stripeCustomerId = user.stripeCustomerId;
    if (!stripeCustomerId) {
      const customer = await stripe.customers.create({ email: user.email });
      stripeCustomerId = customer.id;
      await prisma.user.update({ where: { id: userId }, data: { stripeCustomerId } });
    }
    // Create checkout session for subscription
    const session = await stripe.checkout.sessions.create({
      customer: stripeCustomerId,
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      subscription_data: {
        trial_period_days: 7  // optional trial
      },
      success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
      cancel_url: `${process.env.APP_URL}/pricing?checkout=canceled`
    });
    res.json({ url: session.url });
  } catch (err) {
    console.error("Stripe checkout error:", err);
    res.status(500).json({ error: "Stripe checkout failed" });
  }
});

This would redirect the user to Stripe’s hosted checkout page to enter payment info and subscribe. On success, Stripe will redirect back. We would have a webhook to confirm the subscription.

Recording usage to Stripe: Stripe provides an API to create usage records for metered billing. We might do:

// Pseudo-code: after logging usage in DB, also record to Stripe
if (user.plan === "PRO") {
  await stripe.subscriptionItems.createUsageRecord(
    subscriptionItemId,  // we need to store the Stripe subscription item ID for usage
    { quantity: tokensUsed, timestamp: Math.floor(Date.now()/1000), action: 'increment' }
  );
}

However, to get subscriptionItemId, we need to know which subscription and item corresponds to the usage price. A simpler route: use Stripe’s automatic aggregation by calling usage endpoint once, but actually Stripe expects you to call it for each usage or periodically.

This gets complex, so due to scope, we’ll summarize: our backend should integrate with Stripe’s usage records API to report consumed tokens during the billing cycle. Alternatively, at the end of each user’s cycle, sum their UsageLog and send that as one usage record.

Webhook handling: We set up a Stripe webhook endpoint (Stripe will call it for events like invoice.paid, invoice.payment_failed, customer.subscription.updated, etc.). Example for subscription events:

app.post('/api/billing/webhook', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];
  try {
    const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
    // Handle relevant events
    if (event.type === 'checkout.session.completed') {
      // Subscription created successfully at checkout
      const session = event.data.object;
      // Perhaps update user.plan to "PRO"
      // session.subscription gives subscription ID
    }
    if (event.type === 'invoice.paid') {
      // Invoice paid (could be end of cycle for metered billing)
    }
    if (event.type === 'customer.subscription.updated') {
      // e.g., if canceled or changed
    }
    if (event.type === 'customer.subscription.deleted') {
      // Subscription canceled
      // Maybe downgrade user.plan to "FREE"
    }
  } catch (err) {
    console.log("Webhook signature verification failed:", err);
    return res.sendStatus(400);
  }
  res.sendStatus(200);
});

This endpoint needs to be configured in Stripe dashboard. It must handle the raw body (Stripe signs the payload).

The specifics of Stripe integration can fill an article on its own, but the above gives an idea. The main point is: we tie Stripe’s subscription status to our app’s enforcement. If user doesn’t have a paid subscription (and they exceeded free quota), we block requests. If they do have one, we allow usage and report it to Stripe.

Additional Backend Considerations (Security, Optimization)

Security:

  • We already ensure JWT auth on API calls and isolate user data. We should also validate inputs (e.g., for document upload, perhaps limit length to prevent extremely large content that could cause memory issues, or for any user-provided content sent to Claude, perhaps run it through a sanitizer if needed).
  • Store secrets safely: our ANTHROPIC_API_KEY, JWT_SECRET, and STRIPE_SECRET_KEY should be in environment variables. In production, these might be set in the deployment environment or pulled from a secrets manager. They should never be committed in code or sent to the frontend. For example, when configuring NextAuth or Stripe on the frontend, use only publishable keys or NextAuth’s client IDs (non-secret).
  • Use HTTPS everywhere (which typically is handled by your cloud provider or domain setup; e.g., Vercel enforces HTTPS).
  • If our app deals with potentially sensitive user documents, consider encryption at rest. Postgres could encrypt data on disk, or we might encrypt the content field before saving (so even if DB is compromised the data is not plain). This may be overkill for generic data, but if it’s confidential documents, it’s a consideration.
  • Content moderation: While Claude is designed to avoid unsafe outputs, if our SaaS allows user-generated prompts, we should still follow Anthropic’s usage policies. Anthropic might have a filter built-in, but it’s good to ensure we handle abuse. One idea: use Claude’s own classification abilities or a moderation model to scan user inputs (to avoid extremely hateful or illegal prompts) and either refuse or filter them. This depends on the domain of our app – for an enterprise doc assistant, likely not needed, but for an open-ended writing tool, possibly.
  • Logging: avoid logging sensitive info. For example, we might log that a request happened and tokens used, but not log the full content of user’s documents or queries in plaintext (unless needed for debugging). If logging queries for analytics, consider anonymizing them.

Performance & scaling:

  • The Node backend can be scaled horizontally (multiple instances) behind a load balancer. Because we use stateless JWT auth and a shared DB, any instance can serve any request. If we stored session in memory or had a user-specific in-memory cache, that would break in scaling; instead use Redis or sticky sessions if needed. We already lean stateless with JWT, which is good.
  • If using serverless (AWS Lambda), ensure cold start times are considered (use smaller bundles, keep functions warm, etc.). The Anthropic SDK might need to initialize; possibly keep connections alive if possible.
  • Use a connection pool for Postgres (Prisma by default handles pooling via the DB driver). For serverless, use something like PgBouncer or a provider like Neon that offers connection pooling, because too many Lambdas can exhaust DB connections.
  • We might use caching to reduce load and cost. For instance, if the same user repeats a question on the same doc, we could return a cached result from a previous call (maybe store the last Q&A in a cache keyed by (user, docId, question)). Or at least cache frequently asked questions across users if documents have common content. A cache like Redis could store recent answers for a short time (e.g. 1 hour). However, caching AI outputs is tricky if you expect variation or if context changes. In document Q&A, caching can work because if nothing changed in the doc, the answer will likely be the same.
  • Asynchronous processing: If some requests are very heavy (maybe summarizing a 200k token document could take 30+ seconds), you might not want to tie up the HTTP request that whole time. In such cases, consider an asynchronous pattern: user submits a job (store in DB or a queue), return immediately with a job ID, then process in a background worker (perhaps the Python microservice or a Node worker thread) and notify the user (via WebSocket or polling) when done. This is more complex but improves perceived responsiveness for long tasks.
  • Multi-tenant scaling: If someday a big customer wants their own isolated environment, our design allows deploying a separate instance or even using a separate DB for them (especially if we abstract DB access via Prisma, we could point to a different schema or connection per tenant). The Medium article snippet showed an approach using separate MongoDB per tenant, selected by subdomain. With Postgres, one could use separate schemas or databases per tenant. This is beyond our scope, but worth noting our architecture can evolve in that direction if needed.

Now that we have a solid backend foundation, let’s move to the frontend.

Frontend Implementation (Next.js, React, Tailwind)

Our Next.js frontend is responsible for delivering a great user experience and interfacing with our backend API. We’ll create a dynamic, single-page-app feel (using React hooks and state) for the main application, while still using Next.js features for auth and deployment convenience.

Setting Up Next.js Project

Initialize a Next.js app (with TypeScript and Tailwind):

npx create-next-app@latest ai-saas-app --typescript --tailwind

This will create a Next.js project configured with Tailwind CSS automatically. We then add any needed libraries:

cd ai-saas-app
npm install next-auth axios swr
  • next-auth if we plan to use it for authentication (for demonstration, we might stick to our custom JWT flow).
  • axios or we can use the built-in fetch for API calls. (Axios is optional; Next.js can also use the fetch Web API.)
  • swr (stale-while-revalidate) or React Query for data fetching could be helpful for auto-caching and reloading data (like documents list or usage stats).

Configuring NextAuth (optional): If we wanted to use NextAuth, we’d create [...nextauth].ts in pages/api/auth/ with providers. For example, enabling Google OAuth as in the DEV guide. Due to our custom JWT approach, we might skip NextAuth to avoid confusion, but it’s good to note: NextAuth can also issue JWTs and we could share them with the backend. Since our backend expects a JWT it signed with JWT_SECRET, we could set NextAuth’s JWT secret to the same, making tokens interchangeable. NextAuth’s callback can include the user ID in the token claims. This way, the Express backend could verify NextAuth tokens. This setup is more advanced, but definitely doable – it combines the convenience of NextAuth for login with the flexibility of our separate backend.

For simplicity, let’s assume we will use the custom auth endpoints we made. We can still have Next.js pages for Login/Signup that call our backend API.

Basic Page Structure

We’ll have a Next.js App with pages (or use the App Router). Let’s outline pages:

  • /login page: A form for email/password. On submit, call our backend /api/login. If success (token received), store token (e.g. in a cookie or localStorage) and redirect to dashboard.
  • /register page: Similar form to create account (calls /api/register).
  • /dashboard page: The main app when logged in. It could show a list of documents and usage stats, and have a link to a page or section for the AI assistant.
  • /documents/[id] page or a component in dashboard: This will be the AI assistant interface for a particular document. Alternatively, we may combine uploading and chatting in one place for simplicity.

Given a single-page-app style, we could also implement it such that the dashboard page has internal state for selected document and the chat, without navigating to a new page.

We’ll also have components:

  • <DocumentList> to list documents and handle selecting one.
  • <UploadForm> to upload new document.
  • <ChatInterface> to handle the chat UI with Claude.

Using Tailwind, we can style elements with classes (we won’t focus on the CSS details, but Tailwind makes it easy to e.g. give a container className="max-w-3xl mx-auto p-4" for centered layout, etc.).

Auth token storage: One approach: when user logs in, we call localStorage.setItem('token', token). Then for each API call in the frontend, include that token. We can create a small wrapper for fetch:

function apiFetch(url, options = {}) {
   const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
   const headers = options.headers || {};
   if (token) headers['Authorization'] = `Bearer ${token}`;
   return fetch(process.env.NEXT_PUBLIC_API_URL + url, { ...options, headers });
}

Here, NEXT_PUBLIC_API_URL would be an env var pointing to our backend base URL (e.g. https://api.myapp.com or http://localhost:5000). Using NEXT_PUBLIC_ prefix ensures it’s exposed to the browser code.

For SSR (server side rendering) in Next, we might need to also handle token on the server, but we can skip SSR for pages that require login (just do client-side redirect if not logged in).

Alternatively, store token in a cookie (HTTP-only) by setting it on login response. That’s more secure, but handling that across domains can be tricky. For our scenario, we’ll go with localStorage for ease of understanding (but note: XSS risks, etc., so in production an HttpOnly cookie or NextAuth session is preferable).

Now, some example components and logic:

Login Page (pages/login.tsx):

import { useState } from 'react';
import { useRouter } from 'next/router';

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError("");
    try {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password })
      });
      if (!res.ok) {
        const err = await res.json();
        setError(err.error || "Login failed");
      } else {
        const data = await res.json();
        localStorage.setItem('token', data.token);
        router.push('/dashboard');
      }
    } catch (err) {
      console.error("Login error", err);
      setError("Server error, please try again");
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <form onSubmit={handleSubmit} className="bg-white p-6 rounded shadow-md w-full max-w-sm">
        <h1 className="text-2xl mb-4">Log In</h1>
        {error && <p className="text-red-600">{error}</p>}
        <input 
          type="email" placeholder="Email" value={email}
          onChange={e => setEmail(e.target.value)}
          className="border w-full mb-3 p-2"
        />
        <input 
          type="password" placeholder="Password" value={password}
          onChange={e => setPassword(e.target.value)}
          className="border w-full mb-4 p-2"
        />
        <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded w-full">
          Sign In
        </button>
      </form>
    </div>
  );
}

This is a basic login form with Tailwind styling. On success, it saves the JWT and redirects. We should do something similar for registration.

Dashboard Page (pages/dashboard.tsx):

This page is only for authenticated users. We might add a check in a useEffect to redirect to login if no token is found:

import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';

import DocumentList from '../components/DocumentList';
import ChatInterface from '../components/ChatInterface';
import UsageBar from '../components/UsageBar';

export default function Dashboard() {
  const [documents, setDocuments] = useState([]);
  const [selectedDoc, setSelectedDoc] = useState(null);
  const [usage, setUsage] = useState(null);
  const router = useRouter();

  useEffect(() => {
    const token = localStorage.getItem('token');
    if (!token) {
      router.push('/login');
    } else {
      // Fetch documents and usage info from API
      Promise.all([
        fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/documents`, {
          headers: { Authorization: `Bearer ${token}` }
        }).then(res => res.json()),
        fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/usage`, {
          headers: { Authorization: `Bearer ${token}` }
        }).then(res => res.json())
      ]).then(([docs, usageData]) => {
        setDocuments(docs);
        setUsage(usageData);
      }).catch(err => {
        console.error("Failed to load data", err);
      });
    }
  }, []);

  const handleSelectDocument = (doc) => {
    setSelectedDoc(doc);
  };

  const handleUploadDocument = async (title, content) => {
    // call backend to create doc
    const token = localStorage.getItem('token');
    const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/documents`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
      body: JSON.stringify({ title, content })
    });
    if (res.ok) {
      const { documentId } = await res.json();
      const newDoc = { id: documentId, title, createdAt: new Date().toISOString() };
      setDocuments([...documents, newDoc]);
    } else {
      console.error("Upload failed");
    }
  };

  return (
    <div className="flex h-screen">
      {/* Sidebar: Document list and upload */}
      <div className="w-1/4 bg-gray-50 p-4 border-r overflow-y-auto">
        <h2 className="text-xl font-bold mb-2">Your Documents</h2>
        <DocumentList documents={documents} onSelect={handleSelectDocument} />
        <div className="mt-4">
          <h3 className="font-medium mb-1">Upload New Document</h3>
          {/* A simple form or just a button that triggers file input and reading content */}
          <UploadForm onUpload={handleUploadDocument} />
        </div>
        {usage && <UsageBar usage={usage} />} 
        {/* UsageBar could show a progress bar of tokens used vs limit */}
      </div>
      {/* Main content: Chat interface when a document is selected */}
      <div className="flex-1 flex flex-col">
        {selectedDoc ? (
          <ChatInterface document={selectedDoc} />
        ) : (
          <div className="flex-1 flex items-center justify-center">
            <p className="text-gray-500">Select a document to start asking questions.</p>
          </div>
        )}
      </div>
    </div>
  );
}

In the above:

  • We load docs and usage on mount (once token is present).
  • DocumentList would list documents (titles) and call onSelect when one is clicked.
  • UploadForm allows adding a new document (maybe a textarea for content or a file input – to keep it simple, maybe a textarea for copy-paste text).
  • UsageBar can take usage data (with totalTokens and limit) and display e.g. “10,000 / 100,000 tokens used” and a progress bar.

We won’t detail DocumentList and UploadForm fully, but conceptually:

  • DocumentList: iterate over documents array, show each title (and maybe createdAt). On click, call onSelect(doc). Possibly highlight the selected doc.
  • UploadForm: could have an <input type="file"> and on change, read file text (if text file or PDF via PDF.js etc). Or simpler, a <textarea> for content and an input for title, and a button “Save”. This is fine for a minimal MVP; in production, you want file upload with progress, etc. After uploading, it calls onUpload(title, content) which we provided.

The interesting part is ChatInterface – where the user interacts with Claude on the selected document.

ChatInterface Component (components/ChatInterface.jsx or .tsx):

import { useState, useEffect, useRef } from 'react';

function ChatInterface({ document }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const eventSourceRef = useRef(null);

  useEffect(() => {
    // Reset chat when document changes
    setMessages([]);
    setInput("");
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
    }
  }, [document.id]);

  const sendMessage = async () => {
    if (!input) return;
    const userMsg = { role: 'user', content: input };
    const newMessages = [...messages, userMsg];
    setMessages(newMessages);
    setInput("");
    setLoading(true);
    try {
      const token = localStorage.getItem('token');
      // Start SSE connection for streaming
      const queryParams = document.id ? `?documentId=${document.id}` : "";
      const evtSource = new EventSource(
        `${process.env.NEXT_PUBLIC_API_URL}/api/assistant/query${queryParams}`,
        { withCredentials: false }  // if we used cookies for auth, we'd set withCredentials
      );
      eventSourceRef.current = evtSource;
      let assistantContent = "";
      evtSource.onmessage = (event) => {
        if (event.data === "[DONE]") {
          evtSource.close();
          eventSourceRef.current = null;
          // finalize the assistant message
          const aiMsg = { role: 'assistant', content: assistantContent };
          setMessages(prev => [...prev, aiMsg]);
          setLoading(false);
        } else if (event.data === "[ERROR]") {
          evtSource.close();
          eventSourceRef.current = null;
          setLoading(false);
          // handle error: perhaps show an error message
          alert("Error occurred while getting response.");
        } else {
          // accumulate streaming data
          assistantContent += event.data;
          // We could optimistically update the UI with partial content as well:
          const aiMsg = { role: 'assistant', content: assistantContent + "▍" }; // ▍as blinking cursor
          setMessages(prev => [...prev.filter(m => m.role !== 'assistant' || m.content !== assistantContent), aiMsg]);
        }
      };
      // Send the existing messages and new user message to backend via fetch to initiate SSE
      // Actually, to trigger SSE, we might need to initiate the request differently.
      // Alternatively, use fetch without SSE for simplicity:
      // const res = await fetch('/api/assistant/query', { method: 'POST', headers: {Authorization: Bearer, ...}, body: JSON.stringify({ documentId: docId, messages: newMessages })});
      // then wait for res.json.
    } catch (err) {
      console.error("sendMessage error:", err);
      setLoading(false);
    }
  };

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 p-4 overflow-y-auto">
        {messages.length === 0 && (
          <p className="text-gray-500">Ask a question about "{document.title}"</p>
        )}
        {messages.map((msg, idx) => (
          <div key={idx} className={msg.role === 'user' ? 'text-right' : 'text-left'}>
            <p className={msg.role === 'user' ? 'bg-blue-100 inline-block p-2 m-1 rounded' : 'bg-gray-200 inline-block p-2 m-1 rounded'}>
              <strong>{msg.role === 'user' ? 'You' : 'Claude'}:</strong> {msg.content}
            </p>
          </div>
        ))}
        {loading && messages[messages.length-1]?.role === 'assistant' && (
          <p className="text-left bg-gray-200 inline-block p-2 m-1 rounded">Claude is typing...</p>
        )}
      </div>
      <div className="p-4 border-t flex">
        <textarea 
          className="flex-1 border rounded p-2 mr-2" rows={2}
          placeholder="Type your question..." 
          value={input} onChange={e => setInput(e.target.value)}
          disabled={loading}
        />
        <button onClick={sendMessage} disabled={loading || !input} className="bg-green-600 text-white px-4 py-2 rounded">
          Send
        </button>
      </div>
    </div>
  );
}

export default ChatInterface;

This component displays the conversation and an input box:

  • We maintain messages state, an array of message objects with roles ‘user’ or ‘assistant’.
  • When the user submits (sendMessage), we append the user’s message to the UI, clear the input, and initiate the backend call.
  • For streaming, we used EventSource to listen for messages. However, one complexity: sending the past messages via SSE. SSE is read-only from client side; we can’t send the request body when opening an EventSource except via query params. We added documentId as a param for context. But what about conversation history?
    • We could include a conversation ID param and have the backend pull the conversation from DB. Or we could decide not to support multi-turn follow-ups beyond the document context and one question in MVP. But it would be nice to allow follow-ups.
    • Alternatively, if not using SSE, we could just fetch the endpoint with POST including messages, and not stream (get whole answer then append). Simpler but no streaming UX.
    • A hybrid: open SSE for streaming but still send a POST somehow. SSE doesn’t support sending a body directly. One pattern is to initiate the request via fetch and then switch to reading as stream via ReadableStream. But to keep it simpler, we might not implement fully streaming conversation in the first iteration. Instead, call the API without streaming for now in the front (as an MVP).

Given complexity, we might mention that implementing streaming in the browser might require using the Fetch API with response.body streams or using web sockets. But since our backend SSE sends chunks, we can use a lower-level approach:

const res = await fetch(`${API_URL}/api/assistant/query`, {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ documentId: document.id, messages })
});
const reader = res.body.getReader();
let assistantContent = "";
while(true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunkText = new TextDecoder().decode(value);
  assistantContent += chunkText;
  // update UI with assistantContent...
}

This is how to manually read a fetch stream (if the server flushes data). But explaining this may be too much detail. Instead, one can rely on simpler approach for now and note that streaming can be handled with SSE.

To keep it straightforward, assume we do not implement multi-turn chat beyond using the document. Actually, the requirement did mention “with long-context capabilities” implying multi-turn might be desirable. But due to time, let’s assume each query is independent using the document (the conversation isn’t maintained except by including previous Q&As in messages array which user client already has).

Anyway, the UI as given will show messages (user on right side, AI on left side with styling differences). It appends messages as they come.

Tailwind styling: The classes used (bg-blue-100, bg-gray-200, etc.) are just to differentiate user vs assistant bubbles in a basic way.

UsageBar component: If implemented, it might simply be:

function UsageBar({ usage }) {
  const percent = usage.limit ? Math.min(100, (usage.totalTokens / usage.limit) * 100) : 0;
  return (
    <div className="mt-4">
      <p className="text-sm">Tokens used: {usage.totalTokens} / {usage.limit}</p>
      <div className="w-full bg-gray-300 rounded h-2">
        <div className="bg-green-600 h-2 rounded" style={{ width: percent + '%' }}></div>
      </div>
    </div>
  );
}

This shows a small progress bar.

Frontend Authentication & Session Handling

We manually handled token in localStorage in above code. In a real production Next.js app, we might prefer using NextAuth (which would give us a useSession hook to get user session and automatically handle redirect if not logged in). For instance, in NextAuth, after login, session could include JWT and we could call backend with that. But since we did custom auth, we manually check token.

One improvement: we should consider the JWT expiration (we set 1h expiry). If it expires, calls will start failing with 401/403. We might want to detect that and prompt re-login. If we used refresh tokens or longer sessions, that’s beyond our scope. Simpler: if any fetch returns 401, redirect to login.

We can implement a global fetch wrapper or in each useEffect check for 401 and do router.push('/login').

Additionally, for OAuth, NextAuth would seamlessly handle refresh and cookie. But our goal was to be comprehensive, so we at least mentioned that path.

Frontend and Claude-specific UI considerations

  • Handling large context on client: If a document is huge (say 50k tokens, ~ 200 pages text), sending it to backend every time could be heavy on network. Instead, the backend could store it and just reference it by ID (which we did). The user’s browser doesn’t need to send content each query, just the doc ID. We did that with documentId param.
  • If we wanted to allow multi-turn chat beyond doc content, we might include prior Q&As. In our messages state, we accumulate them, and we did intend to send messages in the body if using a normal fetch. In SSE approach, we can’t easily send it; a workaround is to encode messages in a query param or switch to websockets. But those are advanced real-time patterns. For simplicity, one could drop SSE and use fetch for each query, allowing sending the whole conversation in body (Claude can handle it as long as tokens total < 100k).
  • UI for streaming: We show “Claude is typing…” or partial content with a trailing cursor. This gives a nice feel. We must ensure the UI scrolls to bottom as messages grow (maybe use a ref to last message).
  • Editor integration: If this was an AI writing tool, we might integrate a rich text editor (like TipTap or Quill) and allow the AI to insert edits. That’s a big expansion – our current design is more Q&A oriented, but one could imagine features like “rewrite this paragraph” where user highlights text and the AI returns an edited version. That would involve different UI (like a context menu or an edit mode). We won’t cover that due to scope, but mention that our architecture would support it: it’s basically another type of request (send selected text to Claude with an instruction).
  • File format support: If user uploads PDF, ideally we parse and also perhaps keep the original file. We might store file in S3 and just keep extracted text in DB. If we wanted to let user download or view file, we might need to keep it. But since our focus is analysis, text is fine.

Putting it all together

At this point, we have the key pieces of a full-stack Claude-powered SaaS:

  • Backend: Node/Express app with routes for auth, docs, queries to Claude, logs, and billing integration.
  • Database: Postgres with Prisma models reflecting our data.
  • Frontend: Next.js app with pages for login/register and a main dashboard where users can manage documents and interact with Claude via a chat UI.
  • Integration: The front and back talk via REST API, using JWT for auth, and SSE or standard requests for Claude responses.
  • Billing: Stripe to monetize the service, with usage-based billing being tracked.

Before concluding, let’s address deployment and DevOps considerations as promised.

Deployment, Scaling, and Maintenance

With code in hand, we need to deploy the SaaS to a production environment where real users can access it. Here are strategies for each part of our stack:

Deploying the Frontend (Next.js on Vercel)

Vercel is an ideal platform for Next.js. We can push our Next.js app to a Git repository (e.g., GitHub) and connect it to Vercel. Vercel will automatically build and deploy the app. Some points:

  • We should set environment variables on Vercel: NEXT_PUBLIC_API_URL (to our backend’s URL), and any others needed. Next will bake those in at build time or runtime appropriately.
  • If we used NextAuth, we’d set secrets and provider keys similarly.
  • Vercel will host our static assets (JS bundles) on their CDN and run any Next API routes as serverless functions. In our design, most API routes are handled by our separate Node backend, but if we had some in Next (like the Stripe webhook or NextAuth routes), those would run on Vercel. Vercel’s serverless functions have limits (e.g. 10s execution), which should be fine for quick tasks. For heavy tasks (like calling Claude might exceed 10s for large completions), that’s why we offloaded to the external Node service.
  • We should ensure CORS is configured such that our Vercel frontend domain can talk to the backend domain.

Alternatively, we could deploy everything in a single Next.js app on Vercel. Next.js is capable of handling backend logic; we could have implemented the Claude calls in a Next API route or using Next’s Server Actions. Indeed, Next 14’s Server Actions allow writing server-side code in components, which could directly call the Claude SDK. However, heavy usage might hit limits on Vercel’s functions and complicate long streaming responses. By using a separate service, we have more control and can scale it differently.

Deploying the Backend (Node.js and Database on AWS)

Node.js service: We have several options:

  • AWS Lambda + API Gateway: We can package the Express app into a Lambda function. There’s something called AWS Lambda “Express” compatibility (via AWS HTTP API or using frameworks like Serverless or AWS SAM to configure). Each route could be invoked via API Gateway endpoints. However, streaming responses (SSE) are not straightforward on Lambda/API Gateway (they usually want the full response). AWS API Gateway WebSocket or using AWS AppSync might be needed for real-time streaming, which complicates it. If streaming is critical, a better approach is running a persistent server.
  • AWS ECS (Elastic Container Service) or EKS (Kubernetes): We can dockerize the Node app and run it in a container cluster. For example, use AWS Fargate (serverless containers) with an ECS service. We’d attach an Application Load Balancer to distribute requests to containers. This is more complex to set up initially but handles long-lived connections (like SSE or websockets) easily. We can scale the number of containers based on CPU/memory or request count (Claude calls are mostly waiting on IO, so CPU usage isn’t huge, but heavy usage might need scaling).
  • AWS Elastic Beanstalk: This can deploy a Node app easily (you give it code or container and it manages EC2 instances, load balancing, scaling).
  • DigitalOcean / Heroku / Render: These platforms can also host Node and a Postgres DB easily and may be simpler for early stage. For instance, Render.com can host a Node service with autoscaling and a managed Postgres database.
  • Serverless frameworks: Instead of directly using AWS console, many use frameworks like Serverless Framework, AWS CDK, or Terraform to define their infrastructure as code.

For our scenario, let’s say we choose AWS ECS Fargate:

We write a Dockerfile for the Node app. Something like:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
CMD ["node", "server.js"]

Build and push that image to AWS ECR.

Create an ECS Fargate Task Definition with that image, CPU/memory settings, and env vars (API keys, DB URL, etc. ideally pulling from AWS Secrets Manager for sensitive ones).

Create an ECS Service with that task def, min=1, max maybe=2 tasks to start.

Attach it to an Application Load Balancer (ALB) that listens on port 80/443. Domain name configured to point to ALB (e.g. api.mydomain.com).

Security: ensure the container’s security group allows incoming from ALB, and DB’s security group allows incoming from container (or use a VPC and put both in private subnets).

Auto Scaling: configure ECS to scale out if CPU > X or if request count high. Or manually adjust as needed.

PostgreSQL database: Options:

  • Amazon RDS (Postgres): spin up a Postgres instance. Choose instance size appropriate (maybe db.t3.small for low usage to start). Configure backups, multi-AZ if needed for prod. Get the connection string and use in our backend.
  • Alternatives: Use a hosted Postgres from providers like Supabase, Neon.tech, Heroku Postgres, etc. Many have free tiers for dev and easy scaling.
  • Because our app might have unpredictable load (AI usage), ensure the DB has some headroom in connections and performance. We can also enable PgBouncer for pooling if needed.
  • Apply migrations (if using Prisma: we’d run prisma migrate deploy as part of release, or run it manually).
  • Sensitive data (password hashes, etc.) are stored in DB; ensure the DB is not publicly accessible (only our backend can access it, e.g. same VPC or security rules).

Environment variables management: We have quite a few:

  • ANTHROPIC_API_KEY – should be stored in AWS Secrets Manager or SSM Parameter Store. We can configure ECS Task to inject it from there.
  • JWT_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_URL – all need to be provided securely.
  • NODE_ENV=production for production mode (ensures any dev-only logging is off, etc.).
  • On Vercel side, set NEXT_PUBLIC_API_URL to the domain of our backend API (and maybe if using NextAuth, set providers secrets in Vercel too).

Domain and SSL: Likely, we have a custom domain for marketing and app. For example:

  • Frontend at www.myaiapp.com (pointed to Vercel).
  • Maybe also app.myaiapp.com for the Next app if separating marketing site, but often same.
  • Backend at api.myaiapp.com (pointed to ALB).
  • We’ll get TLS certificates for these (Vercel provides automatically via Let’s Encrypt for front, AWS ACM for the ALB).
  • If using different subdomains, configure CORS accordingly. We did allow our frontend origin in CORS. Also, for cookies if we used them, we’d set correct domain and Secure/SameSite attributes.

Monitoring & Logging:

  • Use CloudWatch in AWS to monitor logs from ECS containers (the Node app’s console logs).
  • Set up alerts if CPU spikes or if error logs appear frequently.
  • Consider using an APM (Application Performance Monitoring) tool like Datadog or New Relic to track response times, memory usage, etc., especially for the Claude requests. But at least capturing metrics such as requests count, average Claude response time, etc., would help. We could add simple logging around Claude calls to measure latency.
  • Also monitor costs: the Anthropic API usage and Stripe charges. The Anthropic Console shows usage and spend. We might want to set up our own alerts if usage gets near our monthly quota.

Scaling Claude usage: If our app grows, we might worry about hitting Anthropic rate limits. They have tiers and you can request higher limits as needed. If truly huge volume, one could even consider hosting open-source models or using other providers to supplement (though currently few can match Claude’s 100k context). But generally, you’d just upgrade your Anthropic plan accordingly. The cost of Claude API is also significant for large token usage, so ensure your pricing to users covers it (stripe usage-based billing helps with that).

Multi-region deployment: Possibly not needed early on, but if you want low latency for users in different geos, you could deploy backend in multiple AWS regions and route via a CDN or geo-aware DNS. For now, picking one region (maybe US-East) is fine, given most latency is in the AI processing.

CI/CD:

  • For the frontend, Vercel hooking to Git acts as CI/CD (push to main = deploy).
  • For backend, we can set up GitHub Actions or similar to build Docker and push to AWS ECR, then trigger an ECS update. Or use a service like AWS CodePipeline. Ensure tests run before deploy.
  • We should also have a staging environment to test new changes (maybe a separate Vercel project and a separate backend ECS service/DB for staging).

Testing: We should write tests for our code:

  • Unit tests for utility functions (if any).
  • Integration tests for API endpoints (possibly using a test DB and mocking Claude API responses).
  • End-to-end tests using something like Cypress or Playwright to simulate user flows: e.g., register -> upload doc -> ask question -> get answer. These could run against a staging environment.
  • Given the complexity with external API, in tests we’d mock Anthropic SDK to return a canned response, so tests are deterministic and don’t consume tokens.
  • The DEV guide emphasized tests as well. In production, having good tests ensures that future changes (like updating the prompt format or upgrading Next.js version) won’t break core features.

Finally, after deployment, watch the usage and performance. Optimize as needed:

  • If certain types of requests are slow or costly, consider optimizations (like caching or adjusting model).
  • Engage with users for feedback (maybe some ask for larger file support or faster responses).
  • Keep an eye on Claude’s updates: Anthropic might release newer models or features (like the Files API to persist documents on their side, or the Skills API to store custom instructions). These could further enhance our SaaS. For instance, the Files API (currently beta) would let us upload documents to Anthropic once and then just reference the file ID in prompts, possibly saving us from sending the entire content every time and saving tokens if the same file is queried repeatedly.

Conclusion

Building a full SaaS application with the Claude API involves much more than just making AI API calls – it requires thoughtful design across the frontend, backend, and infrastructure to create a scalable, secure, and user-friendly product. In this guide, we covered how to integrate Claude’s powerful AI into a modern web application stack:

  • We chose a practical use case (an AI document assistant) that leverages Claude’s unique strengths like long context and reliable responses.
  • We outlined a robust tech stack (Node.js, Next.js, PostgreSQL, Prisma, Stripe) that is commonly used in real SaaS products, ensuring our solution is grounded in production realities.
  • We designed a multi-component architecture separating concerns between the user interface, server logic, and external services, with a strong focus on multi-user security and scalability (e.g., JWT auth, tenant-specific data, horizontal scaling).
  • On the backend, we implemented core features such as authentication, document management, and the Claude integration with streaming responses and rate limiting. We emphasized best practices like storing API keys securely, logging usage, handling errors, and not exceeding rate limits.
  • On the frontend, we built an interactive React app with a chat interface that communicates with the backend APIs, showing how to present AI responses to users in real time. We also handled user flows for login, uploading content, and tracking usage to keep users informed about their consumption.
  • We discussed how to monetize the app via Stripe, using a usage-based billing model that aligns revenue with cost, and how to enforce quotas and handle payments events.
  • Finally, we went through deployment strategies, demonstrating how to deploy the Next.js frontend on a platform like Vercel for ease of use, and the Node backend on AWS for flexibility and power. We touched on environment management, scaling, and monitoring to ensure the app can run reliably in production.

With this foundation, you have a blueprint for creating your own SaaS with Claude or any similar AI model. Of course, you should tailor specifics to your use case – for instance, if your app is an AI writing assistant without document upload, you might simplify some parts, or if it’s a team knowledge base, you might add organization accounts and collaboration features. The important takeaway is to treat the AI as one component in a larger system. Success comes from seamlessly integrating that AI into a well-designed application that users find valuable and trustworthy.

As you build, keep in mind:

  • User Experience: Make the AI feature intuitive (e.g. clear prompts, streaming feedback) and handle failures gracefully (users should get a friendly message if something goes wrong, not a crash or cryptic error).
  • Security & Privacy: AI apps often handle sensitive user data. Protect it. Use secure communication (HTTPS), isolate data per user, and follow compliance needs if any (GDPR etc. if dealing with personal data).
  • Cost Management: Monitor how users utilize the AI and optimize prompts or caching to control costs. For example, if users frequently ask the same question, caching that answer (or using Anthropic’s 50% cost reduction batch API for repetitive queries) can save money.
  • Future Improvements: Perhaps add support for more AI models (maybe let user choose between Claude and other models depending on cost vs quality). Also, track new features from Anthropic – e.g., if they improve the Claude context window or add knowledge base tools, leverage them to keep your SaaS competitive.

By following the guidelines and patterns we’ve discussed, you can build a cutting-edge SaaS application that harnesses the power of Claude’s AI while meeting the high standards of modern web software. The combination of a robust architecture and Claude’s AI capabilities can unlock tremendous value for users – whether it’s supercharging their writing process, automating document analysis, or providing a knowledgeable assistant at their fingertips. Good luck with your build, and happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *