Examples¶
Short recipes for what websites do most. Each is a whole file you can paste : the templates have many more, with comments.
A list with a search and pages¶
pages/products/index.tsx
import { paginate } from 'am-ssr';
import type { PageConfig, PageContext, PageProps } from 'am-ssr';
const TABLE = { instance: 'mongodb', database: 'shop', table: 'products' };
export const config: PageConfig = {
title: 'Products',
cache: { maxAge: 60, scope: 'public', tags: ['products'], query: ['q', 'page'] },
};
export async function loader(ctx: PageContext) {
const q = String(ctx.query.q ?? '').trim().slice(0, 60);
// A text of a visitor is a value of the query, never an operator.
const find = q ? { name: { $regex: q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' } } : {};
const total = await ctx.db.count({ ...TABLE, find });
const pages = paginate({ page: String(ctx.query.page ?? '1'), perPage: 12, total });
const products = await ctx.db.query({ ...TABLE, find, sort: { name: 1 }, skip: pages.skip, limit: pages.limit });
return { q, pages, products };
}
export default function Products({ data }: PageProps<typeof loader>) {
const link = (page: number) => `/products?q=${encodeURIComponent(data.q)}&page=${page}`;
return (
<>
<form method="get"><input type="search" name="q" value={data.q} /> <button>Search</button></form>
<ul>{data.products.map((product) => <li><a href={`/products/${product._id}`}>{product.name}</a></li>)}</ul>
<nav>
{data.pages.items.map((item) =>
item === '…' ? <span>…</span> : <a href={link(item)} class={item === data.pages.page ? 'current' : undefined}>{item}</a>,
)}
</nav>
</>
);
}
The page of a record¶
pages/products/[id].tsx
import { HttpError, notFound } from 'am-ssr';
import type { PageConfig, PageContext, PageProps } from 'am-ssr';
export async function loader(ctx: PageContext) {
try {
const product = await ctx.db.getById({ instance: 'mongodb', database: 'shop', table: 'products', id: String(ctx.params.id) });
if (!product) notFound();
return { product };
} catch (error) {
// An id which is no id, or no such record : the page of 404.
if (error instanceof HttpError && error.status < 500) notFound();
throw error;
}
}
export const config: PageConfig = { title: (data) => data.product.name, description: (data) => data.product.description };
export default function Product({ data }: PageProps<typeof loader>) {
return <h1>{data.product.name}</h1>;
}
The data of each customer, nobody else's¶
site.config.ts
data: {
read: [{ table: 'mongodb/shop/orders', where: { customer_id: '$user._id' }, requireUser: true }],
write: [{ table: 'mongodb/shop/orders', where: { customer_id: '$user._id' }, requireUser: true, allow: ['create'] }],
},
pages/account/orders.tsx
export const config: PageConfig = { title: 'Your orders', access: 'authenticated' };
export async function loader(ctx: PageContext) {
// `find: {}` : the rule adds `customer_id: <the visitor>` to the query.
return { orders: await ctx.db.query({ instance: 'mongodb', database: 'shop', table: 'orders', find: {}, sort: { createdAt: -1 } }) };
}
A saved order gets customer_id of the visitor whatever the page sent ; getById of another customer's order finds nothing.
A part of the website for one group¶
pages/admin/_layout.tsx
import type { LayoutProps, PageConfig } from 'am-ssr';
// Every page of admin/ : the group admin only. The others get 403 before any code of these pages runs.
export const config: PageConfig = { access: { groups: ['admin'] } };
export default function AdminLayout({ children }: LayoutProps) {
return <section class="admin">{children}</section>;
}
The whole website behind a sign-in¶
Who may open a page, decided by code¶
export const config: PageConfig = {
access: {
authenticated: true,
// Run before the loaders : `false` is a 403.
check: async (ctx) => (await ctx.db.count({ instance: 'mongodb', database: 'app', table: 'members', find: { team: String(ctx.params.team) } })) > 0,
},
};
The rule of the table members (where: { user_id: '$user._id' }) keeps the count to the visitor's own memberships.
A JSON API for the scripts of the website¶
pages/api/cart.ts
import { json } from 'am-ssr';
import type { PageContext } from 'am-ssr';
export async function GET(ctx: PageContext) {
return { items: JSON.parse(decodeURIComponent(ctx.cookies.get('cart') ?? '%5B%5D')) };
}
export async function POST(ctx: PageContext) {
// The server checked the token in `x-csrf-token` before this runs.
const slug = String(ctx.body?.slug ?? '');
if (!/^[a-z0-9-]{1,80}$/.test(slug)) return json({ message: 'No such product.' }, { status: 400 });
// ...
return json({ added: slug }, { status: 201 });
}
public/app.js
const { base, csrf } = window.__AM_SSR__;
await fetch(base + '/api/cart', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ slug: 'basalt-mug' }),
});
A webhook of another service¶
pages/hooks/payments.ts
import type { PageConfig, PageContext } from 'am-ssr';
// Other servers call it : no CSRF token, a secret of its own.
export const config: PageConfig = { csrf: false, access: 'public' };
export async function POST(ctx: PageContext) {
const secret = await ctx.cache.get<string>('payments-secret');
if (!secret || ctx.headers['x-signature'] !== secret) ctx.error(401, 'Unknown sender.');
await ctx.db.updateMany({ instance: 'mongodb', database: 'shop', table: 'orders', find: { paymentId: String(ctx.body?.id) }, updateData: { status: 'paid' } });
return { ok: true };
}
Uploading a picture¶
pages/account/avatar.tsx
export const config: PageConfig = { access: 'authenticated' };
export async function action(ctx: PageContext) {
const file = ctx.files.find((one) => one.fieldname === 'avatar');
if (!file || !/^image\/(png|jpeg|webp)$/.test(file.mimetype) || file.size > 2_000_000) {
ctx.status(422);
return { error: 'A PNG, JPEG or WebP image of 2 MB at most.' };
}
ctx.flash('success', 'Saved.');
ctx.redirect('/account', 303);
}
export default function Avatar({ actionData }: PageProps<undefined, { error: string }>) {
return (
<form method="post" enctype="multipart/form-data">
{actionData?.error && <p class="error">{actionData.error}</p>}
<input type="file" name="avatar" accept="image/png,image/jpeg,image/webp" required />
<button type="submit">Upload</button>
</form>
);
}
A part of a page from the server, with htmx¶
pages/tickets/index.tsx
<input type="search" name="q" hx-get="/tickets/rows" hx-trigger="input changed delay:300ms" hx-target="#rows" />
<table><tbody id="rows"><TicketRows tickets={data.tickets} /></tbody></table>
pages/tickets/rows.ts
import TicketRows from '@/components/TicketRows';
export async function GET(ctx: PageContext) {
const tickets = await ctx.db.query({ instance: 'mongodb', database: 'desk', table: 'tickets', find: {}, limit: 20 });
return ctx.html(await TicketRows({ tickets }));
}
hx-get gets the base path of the website by itself, as href does.
A chart¶
export const config: PageConfig = { libraries: ['chartjs'] };
export default function Sales({ data }: PageProps<typeof loader>) {
return (
<>
<canvas id="sales"></canvas>
<script>{`document.addEventListener('DOMContentLoaded', () => new Chart(document.getElementById('sales'), {
type: 'bar', data: ${serialize({ labels: data.labels, datasets: [{ label: 'Sales', data: data.values }] })},
}));`}</script>
</>
);
}
Pages in the language of the visitor¶
lib/i18n.ts
import texts from '@/data/texts.json';
export function t(ctx: PageContext, key: string): string {
const lang = ctx.locale.startsWith('fr') ? 'fr' : 'en';
return (texts as Record<string, Record<string, string>>)[lang]?.[key] ?? key;
}
A page which reads ctx.locale is never kept for everyone : use paths per language (pages/fr/...) for pages you keep.
A feed¶
pages/feed.xml.ts
import { escape } from 'am-ssr';
export async function GET(ctx: PageContext) {
const home = ctx.site.origin + ctx.site.base;
const posts = await ctx.db.query({ instance: 'mongodb', database: 'blog', table: 'posts', sort: { date: -1 }, limit: 20 });
const items = posts.map((post) => `<item><title>${escape(post.title)}</title><link>${home}/posts/${post.slug}</link></item>`).join('');
ctx.setHeader('content-type', 'application/rss+xml; charset=utf-8');
return ctx.text(`<?xml version="1.0"?><rss version="2.0"><channel><title>${escape(ctx.site.title ?? '')}</title>${items}</channel></rss>`);
}
Old addresses, and a pause¶
site.config.ts
redirects: [
{ from: '/shop/*', to: '/products', status: 301 },
{ from: '/twitter', to: 'https://x.com/apimaker', status: 302 },
],
// The website answers 503 with this message, but to the group admin.
maintenance: { message: 'Back at 10:00 UTC.', allowGroups: ['admin'] },
A redirect to another site¶
// Refused without `external` : a `?next=` a visitor wrote never sends to another site.
ctx.redirect('https://checkout.stripe.com/c/pay/' + session.id, 303, { external: true });
Calling a custom API as the visitor¶
export async function action(ctx: PageContext) {
// The access of the API, its hooks, and the token of the visitor.
const quote = await ctx.api.customApi('/shop/quote', { method: 'POST', body: { items: ctx.form.items } });
return { quote };
}