ResQmeals—Fighting Food Waste Together
ResQmeals Project Header

ResQmeals: Architecting a Modular, Multi-Tenant CMS Portal in Next.js

ResQmeals is a mission-driven platform focused on reducing food waste and empowering food merchants to make a difference. The platform connects local food merchants (such as restaurants, caterers, and convenience stores) with individuals and organizations looking for affordable, freshly-prepared surplus meals. This directly benefits communities, reduces environmental impact, and helps merchants earn revenue from otherwise unsold inventory.

During the development of this platform, one of my main responsibilities was architecting and building the modular Content Management System (CMS) portal that powers these merchant operations. Building a multi-tenant portal where users manage different locations, inventories, and item listings required a codebase designed with strong conventions for file structure, form states, server actions, session management, query parameters, and modular scalability. Here’s a look at how we designed and structured this CMS system.

Disclaimer: The architectural patterns and technical details discussed in this article have been reviewed and explicitly approved for publication by the ResQmeals CTO. No proprietary business logic or sensitive data is exposed.


1. Modular Architecture: Escaping the “Spaghetti” Trap

When you build a platform supporting distinct roles like Consumers, Merchants, and Admins, keeping things organized is half the battle. A traditional file-type-based structure (keeping all components in one massive /components folder and all actions in /actions) quickly turns into a tangled web. You end up jumping across five different directories just to add a single field to a form.

To fix this, we adopted Feature-Sliced Design. We structured the App Router architecture by business domain, strictly co-locating logic. Here’s a simplified look at how we organized the dashboard:

app/
└── dashboard/
    └── [feature]/             # e.g., 'inventory', 'orders'
        ├── _components/       # UI specifically for this feature
        ├── _actions/          # Server actions (mutations)
        ├── _queries/          # Data fetching logic
        └── page.tsx           # The entry point

By keeping module-specific components and actions living directly alongside their respective routes, global folders stay clean. If an action is only used by the inventory feature, it stays in the inventory directory, making the codebase much easier to refactor or delete without side effects.


In a dynamic CMS, URLs inevitably change. If you hardcode paths like /dashboard/inventory/${id}/edit across fifty different files, renaming a route turns into a bug-prone search-and-replace nightmare.

We completely avoided this by introducing a centralized, strictly typed route builder. By treating our application’s routes as a single source of truth, URL structures are defined in exactly one place. Better yet, TypeScript will actually throw a compilation error if you forget a required parameter.

// Instead of risky string interpolation:
// router.push(`/dashboard/item/${itemId}/edit`) ❌

// We use a strictly typed central utility:
const targetUrl = generateRoutePath("dashboard.item.edit", { id: 42 }); 
router.push(targetUrl); // Evaluates to: "/dashboard/item/42/edit" ✅

With this setup, autocomplete acts as built-in documentation for the team, making internal link generation completely bulletproof.


3. Form Auto-Saving: Protecting User Effort

CMS dashboards are heavy on data entry. Imagine a merchant spending ten minutes carefully crafting an item description and uploading images, only to accidentally hit the back button and lose everything. That’s a terrible user experience.

To prevent this, we standardized form state management with a custom hook that debounces and serializes drafts straight to browser storage.

// A simplified look at our auto-saving hook in action:
const form = useAutoSaveForm<ItemValues>({
  storageKey: "item-create-draft",
  defaultValues: { name: "", price: 0 },
});

If a user leaves and returns, the hook instantly rehydrates their exact state—including pending file uploads. It’s a small architectural detail, but it practically eliminated complaints about lost data and made the portal feel incredibly stable.


4. Server Actions as the API Gateway

Instead of exposing traditional REST endpoints for client-side consumption, all backend mutations are handled via Next.js Server Actions.

This pattern centralizes authorization logic and multi-tenant context validation. Every action adheres to a strict response contract, typically returning a predictable payload containing success status, messages, and the resultant data.

Server actions extract the current tenant context securely on the server, process the payload, and communicate with backend services. By using standard utility functions for API requests, we automatically manage authentication headers, language preferences, and error handling.

"use server"

export const createItemAction = async (values: ItemPayload) => {
  // 1. Resolve and validate the user's current context securely
  const context = await getRequestContext();
  
  try {
    // 2. Delegate to backend services using a standardized wrapper
    const response = await submitToBackend("/items", values, context);

    if (!response.success) {
      return { success: false, error: response.message };
    }

    // 3. Trigger cache revalidation to update the UI
    return { success: true, data: response.data };
  } catch (error) {
    return { success: false, error: "An unexpected error occurred." };
  }
}

5. Curing “Prop-Drilling” for Server-Side Filters

In Next.js, URL search parameters (?page=2&status=active) are fantastic for driving table filters. However, searchParams are only accessible at the top-level Page component. Passing those parameters down through five layers of Server Components just to reach a Data Table creates miserable prop-drilling.

Our fix was to introduce synchronous request-scoped caching. We intercept the query parameters early in the page render lifecycle and store them in a temporary cache that lives only for the duration of that specific server request.

// 1. Hydrate the query parameters early at the Page level
export default async function Page({ searchParams }: { searchParams: Promise<any> }) {
  const resolvedParams = await searchParams;
  requestCache.setFilters(resolvedParams); // Store in request-scoped cache
  return (
    <DashboardLayout>
      <DataTable />
    </DashboardLayout>
  );
}

// 2. Read synchronously inside a deeply nested component, zero props required!
export default async function DataTable() {
  const filters = requestCache.getFilters();
  const data = await fetchData(filters);
  
  return <Table data={data} />;
}

This pattern kept our component signatures beautifully clean while maintaining perfectly shareable, URL-driven state.


6. Seamless Multi-Tenancy & Context Switching

A big hurdle was that food merchants often operate multiple branches. We needed a way for them to instantly switch their active location without forcing a clunky logout process, while remaining absolutely certain they couldn’t peek into a competitor’s data.

We handled this with secure Server Actions and silent session rotation. Session data is managed exclusively on the backend, so the client never directly manipulates it.

When a user selects a new branch from a dropdown, a Server Action validates their permissions against the database. If authorized, the server silently updates their encrypted session cookie and triggers a seamless Next.js revalidatePath("/").

The UI instantly re-renders with the new branch’s data, feeling just like a single-page app despite being completely server-rendered and secure.


7. Localization Architecture

Supporting multiple regions requires robust internationalization. The platform natively handles English, Malay, and Chinese, driven by a localization architecture that integrates directly with Next.js routing.

To manage localized routes (like /en/dashboard), we use a routing interceptor that checks for stored language preferences or falls back to the browser’s Accept-Language headers, seamlessly redirecting users without exposing the routing logic to the client. Translations are resolved dynamically based on this context, either server-side or through a specialized client context provider.

Finally, to ensure backend services generate localized content and transactional emails correctly, our API request wrappers automatically extract the active locale and append it to outbound requests:

// Example: Automatically injecting context into outgoing service requests
export const internalFetch = async (endpoint: string, options: RequestInit = {}) => {
  const currentLocale = await resolveLocale(); 
  
  const headers = new Headers(options.headers);
  if (currentLocale) {
    headers.set("Accept-Language", currentLocale);
  }

  return fetch(`${process.env.INTERNAL_API_URL}${endpoint}`, { ...options, headers });
};

Summary

Building the ResQmeals platform highlighted the importance of establishing strong architectural patterns early. Features like type-safe API boundaries, persistent form drafts, seamless tenant switching, and integrated localization might seem like invisible details, but together they create a robust, maintainable system and a premium user experience.

By defining clear conventions for routing, server actions, and state management, the architecture remains highly predictable even as the application scales in complexity.


Get Involved!

Are you passionate about reducing food waste? You can start making an impact today!

  • Join ResQmeals to rescue affordable, freshly-prepared surplus meals and help the environment.
  • If you run a food business, join as a Merchant to turn your unsold inventory into revenue while supporting your local community!