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.