#
Discovering "App Template"
This is a guide to help you understand the Quable App template built with Next.js 16 and React 19.
#
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_appmeans 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_ORIGINSenv 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):
- ReactQueryProvider — Enables data fetching with
useQueryanduseMutationhooks. - ThemeContextProvider — Applies the Quable UI design system theme.
- NextIntlClientProvider — Provides internationalization to client components.
- 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:
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:
- Extracts the session ID from the URL.
- Validates the session exists in the database and hasn't expired (24h TTL).
- Redirects to
/errorif the session is invalid. - Sets cookies:
locale(for i18n) andsessionId(httpOnly, secure). - Adds
x-session-idheader 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.
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 (
.scssfiles) - CSS variables: Quable theme tokens (
var(--q-space-500))
You now have a complete overview of the Quable App template architecture. In the next steps, we'll dive deeper into database interactions, the Quable PIM API, and webhook events.