Discovering "App Template"

By
The Quable Team

Project structure
Configuration files
Root layout and providers
Routing
Middleware
API routes
Server Actions
Server vs Client Components
State management
Internationalization
UI layer



Project structure

The Quable App template is built with Next.js App Router, leveraging React Server Components, Server Actions, and file-based routing to provide a modern, performant architecture.

.
├── prisma/
│   ├── schema.prisma              # Database schema definition
│   └── migrations/                # Database migration files
├── scripts/
│   └── banner.js                  # Dev server banner script
├── src/
│   ├── app/                       # Next.js App Router
│   │   ├── layout.tsx             # Root layout with providers
│   │   ├── globals.css            # Global styles
│   │   ├── route.ts               # Main API route (GET/POST /)
│   │   ├── error/
│   │   │   └── page.tsx           # Error page
│   │   ├── readme/
│   │   │   └── page.tsx           # Documentation page
│   │   ├── permission/
│   │   │   └── route.ts           # GET /permission endpoint
│   │   ├── install/
│   │   │   └── route.ts           # POST /install endpoint
│   │   └── [session]/             # Dynamic session routes
│   │       ├── (slots)/           # Grouped slot-based pages
│   │       │   ├── page/
│   │       │   │   └── page.tsx   # Default page slot
│   │       │   ├── document.action.single/
│   │       │   │   └── page.tsx   # Single document action slot
│   │       │   ├── document.action.bulk/
│   │       │   │   └── page.tsx   # Bulk document action slot
│   │       │   └── document.page.tab/
│   │       │       └── page.tsx   # Document page tab slot
│   │       └── product/
│   │           └── page.tsx       # Product listing page
│   ├── components/                # Reusable React components
│   │   ├── SessionDetails.tsx     # Session info display (Server Component)
│   │   └── ToastContainer/
│   │       ├── ToastContainer.tsx # Toast notification wrapper
│   │       └── ToastContainer.scss
│   ├── lib/                       # Shared utilities
│   │   ├── prisma.ts              # Prisma client singleton
│   │   ├── session.ts             # Session management utilities
│   │   └── actions/               # Server Actions
│   │       ├── key-value.ts       # Key-value operations
│   │       └── products.ts        # Product fetching
│   ├── providers/
│   │   └── react-query.tsx        # React Query provider
│   ├── proxy.ts                   # Middleware for session validation
│   └── i18n/
│       └── request.ts             # next-intl configuration
├── translations/
│   ├── en.json                    # English translations
│   └── fr.json                    # French translations
├── .env                           # Environment variables
├── .env.dist                      # Environment template
├── next.config.ts                 # Next.js configuration
├── prisma.config.ts               # Prisma configuration
├── tsconfig.json                  # TypeScript configuration
├── eslint.config.mjs              # ESLint configuration
├── .prettierrc                    # Prettier configuration
├── quable.app.yml                 # Quable App manifest
└── package.json                   # Dependencies and scripts

Configuration files

quable.app.yml

The quable.app.yml file at the root of the project is the Quable App manifest. It declares the application type and the required PIM permissions:

application_type: in_app
quable_pim_scope:
  - full_access
  • application_type: Defines how the app integrates with the PIM (in_app means it runs inside the PIM interface).
  • quable_pim_scope: The permissions your app needs from the PIM.

next.config.ts

The Next.js configuration handles CORS, Server Actions origins, and image optimization:

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins:
        process.env.NODE_ENV === "development"
          ? (process.env.DEV_ALLOWED_ORIGINS || "").split(",")
          : [],
    },
  },
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "**",
      },
    ],
  },
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "Access-Control-Allow-Origin", value: "*" },
          { key: "Vary", value: "Origin" },
          { key: "Access-Control-Allow-Methods", value: "*" },
          { key: "Access-Control-Allow-Headers", value: "*" },
          { key: "Access-Control-Max-Age", value: "86400" },
        ],
      },
    ];
  },
};

const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

Key points:

  • Server Actions CORS: In development, allowed origins are read from DEV_ALLOWED_ORIGINS env variable.
  • Remote images: All HTTPS image hosts are allowed (needed for PIM asset thumbnails).
  • CORS headers: Configured on all routes since the app runs inside the Quable PIM iframe.

Environment variables (.env)

DATABASE_URL="file:./prisma/dev.db"
NEXT_PUBLIC_APP_URL=https://<your-tunnel>.devtunnels.ms
DEV_ALLOWED_ORIGINS=<your-tunnel>.devtunnels.ms
  • DATABASE_URL: SQLite database file path.
  • NEXT_PUBLIC_APP_URL: The public URL of your app (tunnel URL during development).
  • DEV_ALLOWED_ORIGINS: Allowed origins for Server Actions during development.

package.json scripts

{
  "scripts": {
    "dev": "node scripts/banner.js && next dev",
    "build": "node scripts/banner.js && next build",
    "start": "node scripts/banner.js && next start",
    "lint": "eslint",
    "format": "prettier --write .",
    "format:check": "prettier --check ."
  }
}

Root layout and providers

The root layout (src/app/layout.tsx) wraps the entire application with the necessary providers:

import type { Metadata } from "next";
import { Poppins } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { ThemeContextProvider } from "@quable/ui/theme";
import ReactQueryProvider from "@/providers/react-query";
import { ToastContainer } from "@/components/ToastContainer/ToastContainer";

import "@quable/ui/theme/index.css";
import "@quable/ui/index.css";
import "./globals.css";

const poppinsFont = Poppins({
  variable: "--font-poppins-sans",
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
});

export const metadata: Metadata = {
  title: "Quable App Template",
  description: "Quable App Template with Next.js and @quable/ui",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <body className={`${poppinsFont.className} antialiased`}>
        <ReactQueryProvider>
          <ThemeContextProvider>
            <NextIntlClientProvider>{children}</NextIntlClientProvider>
            <ToastContainer />
          </ThemeContextProvider>
        </ReactQueryProvider>
      </body>
    </html>
  );
}

Provider chain (from outer to inner):

  1. ReactQueryProvider — Enables data fetching with useQuery and useMutation hooks.
  2. ThemeContextProvider — Applies the Quable UI design system theme.
  3. NextIntlClientProvider — Provides internationalization to client components.
  4. ToastContainer — Renders toast notifications (react-toastify).

Routing

The template uses the Next.js App Router with file-based routing.

Session-based dynamic routes

All user-facing pages live under src/app/[session]/, where [session] is a dynamic parameter containing the session UUID:

src/app/[session]/
├── (slots)/                          # Route group (no URL segment)
│   ├── page/page.tsx                 # → /:sessionId/page
│   ├── document.action.single/page.tsx  # → /:sessionId/document.action.single
│   ├── document.action.bulk/page.tsx    # → /:sessionId/document.action.bulk
│   └── document.page.tab/page.tsx       # → /:sessionId/document.page.tab
└── product/page.tsx                  # → /:sessionId/product

Slot system

Quable PIM uses a slot system to integrate apps into different areas of the interface:

Slot Description
page Full page displayed in the AppStore section
document.action.single Action triggered on a single document
document.action.bulk Action triggered on multiple selected documents
document.page.tab Custom tab on a document's page

When the PIM calls your app, it sends a POST request with the slot name. Your app creates a session and returns the URL for the appropriate slot page.


Middleware

The middleware file (src/proxy.ts) intercepts all requests to session-based routes and validates the session:

import { NextRequest, NextResponse } from "next/server";
import { getSession } from "./lib/session";

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)",
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

export const proxy = async (request: NextRequest) => {
  const { nextUrl } = request;
  const pathname = nextUrl.pathname + (nextUrl.search || "");

  let response: NextResponse;
  if (pathname.match(/^\/[a-zA-Z0-9-]+\/.+$/)) {
    const sessionId = pathname.split("/")[1];
    const session = await getSession(sessionId);
    if (!session) {
      return NextResponse.redirect(new URL("/error", request.url));
    }

    const requestHeaders = new Headers(request.headers);
    requestHeaders.set("x-session-id", sessionId);

    response = NextResponse.next({
      request: { headers: requestHeaders },
    });

    response.cookies.set("locale", session.interfaceLocale);
    response.cookies.set("sessionId", sessionId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
    });
  } else {
    response = NextResponse.next();
  }

  return response;
};

For routes matching /:sessionId/*, the middleware:

  1. Extracts the session ID from the URL.
  2. Validates the session exists in the database and hasn't expired (24h TTL).
  3. Redirects to /error if the session is invalid.
  4. Sets cookies: locale (for i18n) and sessionId (httpOnly, secure).
  5. Adds x-session-id header to the request for downstream use.

API routes

Root route (src/app/route.ts)

Handles two flows for session creation:

GET / — AppStore flow (user opens the app from the PIM AppStore):

export async function GET(request: NextRequest): Promise<NextResponse> {
  const { searchParams } = new URL(request.url);
  const applicationType = searchParams.get("applicationType");
  const quableInstanceName = searchParams.get("quableInstanceName");
  // ... extract interfaceLocale, dataLocale, userId

  if (applicationType === "AppStore" && quableInstanceName && ...) {
    const instanceData = await prisma.quableInstance.findUnique({
      where: { name: quableInstanceName },
    });
    const session = await prisma.session.create({
      data: { userId: parseInt(userId, 10), dataLocale, interfaceLocale, quableInstanceId: instanceData.id },
    });
    return NextResponse.redirect(
      new URL(`${process.env.NEXT_PUBLIC_APP_URL}/${session.id}/page`, request.url)
    );
  }
  return NextResponse.json({ message: "Service is running" });
}

POST / — Slot flow (PIM triggers an app slot):

export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = await request.json();
  const { searchParams } = new URL(request.url);
  const slot = searchParams.get("slot");
  const { data } = body;

  // Validate instance, create session with documentIds
  const session = await prisma.session.create({
    data: {
      userId: data.userId,
      dataLocale: data.dataLocale,
      interfaceLocale: data.interfaceLocale,
      documentIds: data.documentIds ?? [],
      quableInstanceId: instanceData.id,
    },
  });

  return NextResponse.json({
    url: `${process.env.NEXT_PUBLIC_APP_URL}/${session.id}/${slot}`,
    err: 0,
  });
}

Install route (src/app/install/route.ts)

Called when the app is installed on a Quable PIM instance:

import prisma from "@/lib/prisma";
import { QuablePimClient } from "@quable/quable-pim-js";
import { NextResponse } from "next/server";

export async function POST(request: Request): Promise<NextResponse> {
  const payload = await request.json();
  const { quableInstanceName, quableAuthToken } = payload.data;

  // Validate the token by making a test API call
  const pimClient = new QuablePimClient({
    apiToken: quableAuthToken,
    instanceName: quableInstanceName,
  });
  await pimClient.API.REST.User.getAll({ limit: 1, type: "api" });

  // Store or update the instance credentials
  await prisma.quableInstance.upsert({
    where: { name: quableInstanceName },
    create: { name: quableInstanceName, token: quableAuthToken },
    update: { name: quableInstanceName, token: quableAuthToken },
  });

  return NextResponse.json(
    { message: `QuableApp installed on ${quableInstanceName}.quable.com` },
    { status: 200 }
  );
}

Permission route (src/app/permission/route.ts)

Returns the app's required PIM permissions (read from quable.app.yml):

import { NextResponse } from "next/server";
import { readFile } from "fs/promises";
import * as yaml from "js-yaml";
import { join } from "path";

export async function GET(): Promise<NextResponse> {
  const filePath = join(process.cwd(), "quable.app.yml");
  const yamlContent = await readFile(filePath, "utf8");
  const config = yaml.load(yamlContent) as { quable_pim_scope: string[] };
  return NextResponse.json(config.quable_pim_scope);
}

Server Actions

Server Actions replace the traditional controller/service pattern. They are functions marked with "use server" that run on the server and can be called directly from client components.

Server Actions are located in src/lib/actions/:

// src/lib/actions/products.ts
"use server";

import { QuablePimClient } from "@quable/quable-pim-js";
import prisma from "../prisma";
import { getCurrentSession } from "../session";

export async function getProducts() {
  const session = await getCurrentSession();
  if (!session) throw new Error("Session not found");

  const quableInstance = await prisma.quableInstance.findUniqueOrThrow({
    where: { id: session.quableInstanceId },
  });

  const quablePimClient = new QuablePimClient({
    apiToken: quableInstance.token,
    instanceName: quableInstance.name,
  });

  const products = await quablePimClient.API.REST.Document.getAll({});
  return products.map((product) => ({
    image: product.mainAssetThumbnailUrl,
    name: product.attributes[`${product.documentType.id}_name`]?.[session.dataLocale] || product.id,
    code: product.id,
    productType: product.documentType.id,
  }));
}

Server Actions are then called from client components using React Query:

"use client";
import { useQuery } from "@tanstack/react-query";
import { getProducts } from "@/lib/actions/products";

const { data: products, isLoading } = useQuery({
  queryKey: ["products"],
  queryFn: () => getProducts(),
});

Server vs Client Components

In Next.js App Router, components are Server Components by default. Add "use client" at the top of a file to make it a Client Component.

Feature Server Component Client Component
Runs on Server only Server + Client
Can access Database, file system, env vars Browser APIs, hooks
Can use async/await directly useState, useEffect, useQuery
Directive None (default) "use client"

Example Server ComponentSessionDetails.tsx:

import { getCurrentSession } from "@/lib/session";

export default async function SessionDetails() {
  const session = await getCurrentSession();
  if (!session) return <Typography>No active session</Typography>;

  return (
    <TableContainer component={Paper}>
      {/* Render session data directly — no hooks needed */}
    </TableContainer>
  );
}

Example Client Component — Form with mutations:

"use client";
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";

export default function HomePage() {
  const { register, handleSubmit } = useForm();
  const { mutateAsync } = useMutation({
    mutationFn: (data) => addKeyValue(data.key, data.value),
  });
  // ...
}

State management

The template uses React Query (TanStack Query) for server state management.

The provider is set up in src/providers/react-query.tsx:

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

function ReactQueryProvider({ children }: { children: ReactNode }) {
  const [client] = useState(new QueryClient());
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

export default ReactQueryProvider;

Fetching data with useQuery:

const { data, isLoading } = useQuery({
  queryKey: ["products"],
  queryFn: () => getProducts(),
});

Mutating data with useMutation:

const { isPending, mutateAsync } = useMutation({
  mutationFn: (data: { key: string; value: string }) =>
    addKeyValue(data.key, data.value),
  onSuccess: (data) => {
    if (data.keyValue) toast.success("Success!");
  },
});

Internationalization

The template supports multiple languages using next-intl.

Configuration (src/i18n/request.ts)

import { getRequestConfig } from "next-intl/server";
import { cookies } from "next/headers";

const SUPPORTED_LOCALES = ["en", "fr"];
const DEFAULT_LOCALE = "en";

export default getRequestConfig(async () => {
  const cookieStore = await cookies();
  const sessionLocale = cookieStore.get("locale")?.value;
  const locale = sessionLocale?.split("-")[0] ?? DEFAULT_LOCALE;
  const selectedLocale = SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
  const messages = (await import(`../../translations/${selectedLocale}.json`)).default;
  return { locale: selectedLocale, messages };
});

The locale is determined from the locale cookie, which is set by the middleware based on the user's interfaceLocale.

Translation files

Translations are stored in translations/en.json and translations/fr.json:

{
  "common": {
    "app_name": "Quable App Template",
    "save": "Save",
    "key_required": "Key is required",
    "value_required": "Value is required"
  },
  "products_page": {
    "title": "Products",
    "columns": {
      "product_name": "Product Name",
      "product_code": "Product Code"
    }
  }
}

Usage in components

Client components use the useTranslations hook:

"use client";
import { useTranslations } from "next-intl";

export default function MyComponent() {
  const t = useTranslations("common");
  return <Button>{t("save")}</Button>;
}

Server Actions use getTranslations:

"use server";
import { getTranslations } from "next-intl/server";

export async function myAction() {
  const t = await getTranslations("common");
  return { error: t("toasts.error_message") };
}

UI layer

The template uses @quable/ui (Quable's design system built on top of MUI) combined with Tailwind CSS for styling.

@quable/ui components

import { TextField, Button, DataTable, EmptyState, Chip } from "@quable/ui";

MUI components

import { Box, Card, CardHeader, CardContent, Typography, Grid } from "@mui/material";
import { OpenInNew } from "@mui/icons-material";
import { GridColDef } from "@mui/x-data-grid-pro";

Styling options

  • Tailwind CSS: Utility classes (className="p-4 flex items-center")
  • MUI sx prop: Inline styles (sx={{ display: "flex", justifyContent: "center" }})
  • Sass: Component-level styles (.scss files)
  • CSS variables: Quable theme tokens (var(--q-space-500))