Next.js,

A customer portal can start with a few forms and grow into orders, approvals, imports, notifications and management reports. The challenge is not just adding screens. It is keeping the rules understandable while work happens across different people, systems and timescales.
One useful foundation is Next.js for the user experience, a modular NestJS backend for business functionality, and a separately runnable worker for slow or asynchronous work. Add a database and a job queue, and you have room for sophisticated workflows without making every business capability a microservice.
This is a design choice, not a compulsory stack. It costs more to operate than a single web application. The payoff is a clear home for business rules and a way to keep long-running tasks out of interactive requests.
The shape of the application
The frontend renders pages and helps people complete tasks. The API owns the rules behind those tasks. The worker carries out work that can finish later: transactional emails, file imports, report generation and third-party synchronisation.
A queue is supporting infrastructure. It does not require Customers, Orders and Notifications to become separately networked services. Those can remain modules in one backend codebase—a modular monolith—with the worker using the same appropriate application services.
NestJS modules make dependencies visible
NestJS gives teams a consistent vocabulary: controllers handle HTTP entry points, services implement application behaviour, and modules group related capabilities. In @Module() metadata:
importsbrings in other modules whose exported providers this module needs;controllersregisters this module’s HTTP controllers;providersregisters services and other injectable dependencies; andexportsdeclares which providers other importing modules may use.
Here is a small, self-contained teaching example. Its customer records are deliberately in memory, and its read-only endpoint omits authentication so the module wiring stays visible. It is not a production customer store or a complete API security example.
import {
Controller,
Get,
Injectable,
Module,
NotFoundException,
Param,
} from "@nestjs/common";
@Injectable()
export class CustomersService {
private readonly customers = new Map([
["customer-1", { id: "customer-1", name: "Example Customer" }],
]);
getRequired(id: string) {
const customer = this.customers.get(id);
if (!customer) throw new NotFoundException("Customer not found");
return customer;
}
}
@Module({
providers: [CustomersService],
exports: [CustomersService],
})
export class CustomersModule {}
@Injectable()
export class OrdersService {
constructor(private readonly customers: CustomersService) {}
previewForCustomer(customerId: string) {
const customer = this.customers.getRequired(customerId);
return { customerId: customer.id, customerName: customer.name };
}
}
@Controller("orders")
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Get("preview/:customerId")
preview(@Param("customerId") customerId: string) {
return this.orders.previewForCustomer(customerId);
}
}
@Module({
imports: [CustomersModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
@Module({ imports: [OrdersModule] })
export class AppModule {}
OrdersModule consumes CustomersService because it imports CustomersModule, which explicitly exports that provider. Orders does not need to know how customer records are stored. It should not reach into another module’s private repository or register a second copy of CustomersService just to satisfy injection.
Exports form a module’s public interface; they are not public HTTP endpoints. For example, a Notifications module might export a service for scheduling an order-confirmation email while keeping template selection and delivery adapters private.
Dependency injection also makes testing less dependent on infrastructure. With Nest’s testing utilities, a test can import the real Orders module but replace the customer provider:
import { Test } from "@nestjs/testing";
import { CustomersService, OrdersModule, OrdersService } from "./example";
const testingModule = await Test.createTestingModule({
imports: [OrdersModule],
})
.overrideProvider(CustomersService)
.useValue({
getRequired: (id: string) => ({ id, name: "Test Customer" }),
})
.compile();
const preview = testingModule
.get(OrdersService)
.previewForCustomer("customer-test");
expect(preview.customerName).toBe("Test Customer");
await testingModule.close();
This excerpt assumes a test runner providing expect and a TypeScript configuration supporting the shown top-level await. The useful seam is the replaceable provider: business behaviour can be checked without connecting to a database. Integration tests still need to exercise the real persistence and authorization paths.
Consistent conventions help a new developer find the customer rules, understand an order dependency and change one capability without hunting across the whole application. They help collaboration and onboarding, but Nest does not design good domain boundaries for you. Keep responsibilities clear, export only what others need, avoid circular dependencies, and do not make every module global to hide the dependency graph.
Next.js owns the experience; NestJS owns the business rules
Next.js can handle page layouts, server rendering, interactive forms, loading states and feedback. In the App Router, Server Components and Client Components let you choose what runs on the server and what needs browser interactivity.
The proposed NestJS backend handles validation, authorization, business rules and data access. A button being disabled in the frontend is useful feedback, not protection: someone can call the API without using your interface.
Next.js has backend capabilities too, including Route Handlers and backend-for-frontend patterns. A smaller application may be simpler with business functionality there. A separate NestJS API earns its overhead when a substantial domain benefits from shared conventions, when several clients need the same functionality, or when backend and frontend release or capacity needs differ. The choice introduces an API contract, another deployment and authentication integration; it is not free separation.
Browser calls and server calls are different boundaries
For a report screen, a browser interaction could call NestJS directly. This Client Component helper assumes the authentication integration supplies a short-lived user access token intended for this API:
"use client";
export async function requestReport(
accessToken: string,
from: string,
to: string,
): Promise<{ jobId: string }> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/reports`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from, to }),
},
);
if (response.status !== 202) {
throw new Error("The report request was not accepted");
}
return response.json();
}
NEXT_PUBLIC_API_URL is a public API address, not a credential. The UI should catch errors, handle expired sessions, prevent accidental repeat submissions and show the accepted job identifier with a pending state. A server-enforced idempotency key is needed if repeated submissions must represent one request; disabling the button alone is insufficient.
For server-rendered reads, a Next.js Server Component can call NestJS directly from the Next.js server. Mutations can also pass through an authenticated Next.js Route Handler or Server Action. That same-origin approach can keep a user’s session in an HTTP-only cookie rather than handing a bearer token to browser JavaScript, but adds a server hop and its own security checks. Server-side fetch does not automatically forward the browser’s identity: deliberately exchange or forward the appropriate user credential. Never substitute an unrestricted service token for user-level authorization.
Whichever path you choose:
- Authenticate at the backend boundary. NestJS must verify the session or token, including the expected issuer, audience and expiry where applicable.
- Authorize the action and the record. Check tenant membership, report permissions and ownership on creation, status queries and downloads. Derive the tenant and requester from verified identity, not a trusted-looking request body. Nest’s authorization guidance explains guards and policy checks.
- Validate on the backend. Check date ranges, allowed report types and workload limits. Client-side validation improves the experience but cannot enforce these rules.
- Configure cross-origin browser access deliberately. Direct calls to another origin require appropriate NestJS CORS configuration, including allowed origins, methods and headers. Cookie-based cross-origin calls also need credentials and suitable cookie settings; credentialed CORS cannot use a wildcard origin. CORS is not authentication. Cookie-authenticated mutations need CSRF protection.
- Keep secrets server-side. Database credentials, Redis credentials, signing keys and privileged integration tokens belong in server configuration, never
NEXT_PUBLIC_variables or browser bundles. Next.js documents which environment variables become public. Keep tenant-specific server fetches and responses out of shared caches.
A worker keeps slow work out of the request
A report might take minutes. Keeping the original HTTP request open makes failures harder to recover from and leaves the user guessing. Instead, accept the work and let the interface show progress.
BullMQ provides queues and workers backed by Redis; NestJS also has an official BullMQ integration. Run the consumer as its own process, not an unawaited promise inside a web request or a worker accidentally started by every API instance.
A report request, end to end
- The frontend sends
POST /reports. The API authenticates the user, authorizes the report scope and validates the input. - The API stores a report request with an opaque identifier, tenant, requester, validated parameters and
pendingstate, then awaits enqueue. In this example the report identifier is also the publicjobId. - Once enqueue succeeds, the API returns
202 Acceptedwith{ jobId }. The immediate response means “accepted for background work”, not “report generated”. The database/queue gap in this direct flow needs attention, as discussed below. - A worker consumes the job, records
processing, generates the report, stores the output in private storage and persistscompletedplus the result reference in the database. Persist the result before returning successfully from the processor. - The frontend polls
GET /reports/:jobIdusing its authenticated request path. The API checks access against the stored tenant/requester and returns a safe application status:pending,processing,completedorfailed. A completed response can offer an authorized download endpoint or short-lived download URL.
Use a modest polling interval, back off on transient errors, stop on completion or failure, and pause or stop when the screen is left. Treat 401/403 as session/access problems, not instructions to poll forever. The database record is the durable user-facing status; a Redis job entry is not an access-control mechanism or a permanent business audit record. Do not expose the queue directly to the browser.
Queue and worker excerpts
The following producer function shows the BullMQ boundary. Its caller has already created and authorized the report request. Supply a configured, long-lived Queue named reports; do not open a new Redis connection on every HTTP request.
import { Queue } from "bullmq";
export async function enqueueReport(queue: Queue, reportId: string) {
await queue.add(
"generate-report",
{ reportId },
{
jobId: reportId,
attempts: 3,
backoff: { type: "exponential", delay: 1000 },
removeOnComplete: { count: 1000 },
removeOnFail: false,
},
);
return { jobId: reportId };
}
Use an identifier such as a UUID without : characters for the custom BullMQ job ID. Three attempts means an initial attempt and up to two retries for ordinary processor errors; the exponential backoff here starts at one second. These are illustrative settings, not a reliability budget for every workload.
Here is a separate worker entry point using BullMQ directly with Nest’s standalone application context:
import { NestFactory } from "@nestjs/core";
import { Worker } from "bullmq";
import { ReportsApplicationModule } from "./reports/application.module";
import { ReportsService } from "./reports/reports.service";
import { workerRedisConnection } from "./server-config";
async function main() {
const app = await NestFactory.createApplicationContext(
ReportsApplicationModule,
);
const reports = app.get(ReportsService);
const worker = new Worker<{ reportId: string }>(
"reports",
async (job) => {
if (job.name !== "generate-report") {
throw new Error("Unsupported job type");
}
await reports.generateAndPersist(job.data.reportId);
},
{ connection: workerRedisConnection, concurrency: 2 },
);
worker.on("error", (error) => console.error("Worker error", error));
worker.on("failed", (job, error) =>
console.error("Report attempt failed", job?.id, error),
);
let closing = false;
const close = async () => {
if (closing) return;
closing = true;
await worker.close();
await app.close();
};
process.once("SIGTERM", () => void close());
process.once("SIGINT", () => void close());
}
main().catch((error) => {
console.error("Worker startup failed", error);
process.exitCode = 1;
});
ReportsApplicationModule, ReportsService and workerRedisConnection are application-defined dependencies, not framework exports. This excerpt shows the process boundary, not a complete report implementation. The module should register/export the report service and import its database and storage dependencies without importing HTTP controllers or starting duplicate consumers. Both API and worker use that application layer; each process has its own instances.
generateAndPersist must implement the status transitions, tenant-scoped data access, idempotent output storage and durable completion described above. Nest HTTP guards and pipes do not run automatically when a standalone worker calls a service. Validate the job’s stored business context and apply domain policies in the application layer. Decide whether sensitive work must recheck current permissions if access changes while queued.
Load Redis settings from validated server configuration, using authentication/TLS as required by the deployment. The producer and consumer must agree on Redis database, queue name and any prefix. With ioredis, workers need maxRetriesPerRequest: null; HTTP producers should fail promptly rather than waiting indefinitely when Redis is unavailable. See BullMQ’s connection and production guidance. A real deployment also needs readiness checks, startup cleanup on failure and a shutdown grace period long enough for active work—or safe recovery if it is killed.
Retries are a recovery tool, not exactly-once execution
BullMQ documents bounded retries and backoff. Retry transient network or provider failures, not invalid input indefinitely. Set external-call timeouts, classify permanent failures and cap retry budgets. A crashed or stalled worker can cause work to run again, and CPU-heavy synchronous processing can prevent timely lock renewal. Consider sandboxed processors or dedicated compute for that workload.
Design for repeat execution. For a report, use a durable unique request ID, an output key derived from it, and database constraints or atomic transitions that prevent conflicting concurrent attempts. If already completed, return without regenerating it. For an email or third-party write, use the provider’s idempotency mechanism where available and persist a delivery ledger; a crash after sending but before recording success can otherwise send twice. Where the provider cannot deduplicate, acknowledge that residual risk and plan reconciliation.
A custom queue job ID suppresses duplicate enqueue only while that job remains in the queue; after removal, it can be added again. It is not a substitute for idempotent domain operations, and this architecture makes no exactly-once or unconditional delivery promise.
Make failures visible to both operators and users. The excerpt retains failed jobs for investigation, so production needs a deliberate retention/cleanup policy. Record attempts and sanitized errors; keep an application status pending or retrying during recovery, and mark failed after terminal failure. A worker/queue event handler plus reconciliation of abandoned processing records should keep the durable status accurate even if a process dies before updating it. Do not depend solely on an in-process failure callback as a permanent audit trail.
Alert on rising oldest-job age, queue backlog, terminal failures and worker health. Correlate API requests, report IDs and job attempts in structured logs/traces; measure queue wait time separately from processing duration. Restrict operational dashboards, avoid logging credentials or report contents, and require an authorized, auditable retry action. For transactional email, imports and synchronisation, the same pattern applies with domain-specific status and deduplication rules.
The database and queue do not share a transaction
The direct sequence has a gap: the report row can commit, then the API can crash before enqueue. Enqueue can also succeed while the connection drops before its acknowledgement arrives. Simply writing the row and calling queue.add() does not make them atomic, and moving enqueue before the database write creates the opposite problem.
At minimum, surface enqueue errors rather than returning a false success, retain a recoverable request state and reconcile pending requests. If the workflow requires every committed request to have a durable intent to run, use a transactional outbox:
- In one database transaction, store the report request and an outbox record describing the work.
- A relay reads committed outbox records, enqueues them and marks them dispatched only after acknowledgement.
- Retry dispatch failures and alert on old undispatched records. The relay can be part of the worker deployment; it need not become another business microservice.
In that version, 202 can mean the request and its dispatch intent are committed, even if Redis is temporarily down—not that the job is already in Redis. Make this contract explicit. The relay can crash after enqueue but before marking dispatch, so it may enqueue again; consumers still need idempotency. The outbox closes the database/dispatch-intent gap, not every failure window in storage, Redis or an external provider. It also needs monitoring, retention and a recovery plan.
What you gain—and what you now operate
You gain predictable structure, clearer team conventions and fewer network boundaries than a service-per-domain design. Keeping business modules together makes many changes and debugging sessions easier. Separating the worker stops a report or import from tying up an interactive request and gives background work its own capacity controls.
But there are three deployables to configure, secure, monitor and release:
| Deployable | Runtime and scaling responsibility |
|---|---|
| Next.js frontend | Hosting appropriate to its rendering features, API and session integration; scale for page rendering and user traffic |
| NestJS API | An application runtime, database access, API contracts and authorization; scale for interactive business requests |
| Worker | A runtime suited to job duration, queue access, domain dependencies and graceful shutdown; scale job processing with bounded concurrency |
There is still a shared database and queue to operate. Plan migrations compatible with rolling API/worker releases and versioned job payloads already waiting in Redis. Cap database connection pools across all replicas. Protect the queue on a private network where possible, configure Redis persistence and noeviction, monitor memory and test recovery. Persistence and failover settings affect how much queued work can be lost; Redis should not be treated as a disposable application cache. Back up the database and manage private report storage retention as well.
More workers do not automatically mean faster completion. They can overload the database, exhaust memory, contend on the same records or exceed an external API’s rate limits. Separate queues and concurrency limits can keep a long import from starving urgent notifications without splitting the business into microservices.
Scale from measurements, not a messages-per-second slogan
There is no universal “thousands of messages per second” cutoff. A lightweight notification and a large spreadsheet import are different workloads. Capacity depends on job complexity, acceptable waiting time, concurrency, database queries, external limits and the resources you actually provision.
Load-test representative interactive requests and jobs together. Measure API latency percentiles, queue age, processing time, retry rate, CPU/memory and database contention. Find the limiting resource before adding replicas. Scale frontend, API and worker separately where that helps, then check whether the bottleneck moved to the database or an external service.
Some workloads justify another approach: strict low-latency processing may not tolerate a background queue; sustained high event rates may need partitioned streaming infrastructure; strong security or fault isolation may require separate data and runtime boundaries; independently owned services may need independent contracts and releases. Those are concrete requirements, not evidence that every growing application must become microservices.
For many customer portals and business systems, a modular backend and a worker are a viable starting point—and can remain sufficient as the application grows. The next step is to map one workflow, define which actions need an immediate answer and which can finish later, then choose the smallest architecture that meets its reliability and operating needs. Tell us how your workflow runs, and we can help identify those boundaries.
Official documentation
The examples are illustrative API patterns, not a claim of a tested deployment of this stack. The framework and queue behaviour above is grounded in these official references:
- NestJS: modules and exports, testing and provider overrides, authorization, CORS, BullMQ integration, and standalone application contexts.
- Next.js: Server and Client Components, backend for frontend, and environment variables.
- BullMQ: connections, job IDs, retries and backoff, idempotent jobs, stalled jobs, and production considerations.
- AWS Prescriptive Guidance: transactional outbox pattern, for the dual-write problem and duplicate-message considerations; adopting the pattern does not require hosting this application on AWS.