# 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 Component — SessionDetails.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))