- TypeScript 75.4%
- Svelte 20.3%
- JavaScript 3%
- HTML 1.3%
| assets | ||
| e2e | ||
| src | ||
| static | ||
| .env.example | ||
| .gitignore | ||
| .npmrc | ||
| .prettierignore | ||
| .prettierrc | ||
| contact.sql | ||
| data.json | ||
| drizzle.config.ts | ||
| local.db.bak | ||
| package.json | ||
| playwright.config.ts | ||
| products.sql | ||
| README.md | ||
| recipes.sql | ||
| students.sql | ||
| svelte.config.js | ||
| tsconfig.json | ||
| vite.config.ts | ||
Lab 12: API and Client
Enda Lee 2025
Getting Started
- Clone or download this repository.
- Open the app folder in Visual Studio Code.
- Create a .env file in the application root using .env.example as a template.
- Rename
local.db.baktolocal.db - In the terminal, run npm install to install dependencies.
- Start the development server with npm run dev.
Overview
The examples in previous labs were self-contained fullstack applications which access their own data stores. While this works well, there are times when you will want to share data between different client applications - for example, a web app and website could share the same data source. This can be achieved by accessing all data via a shared API.
The example database consists of 2 tables: productCategory and product, which are generated using the Drizzle ORM as before and stored in the local.db SQLite database.
You will find the data access and service functions in src\lib\server. Note the types exports in schema.ts. Theses are used throughout the application allowing TypeScript to validate types.
// Type exports
export type ProductCategory = typeof productCategory.$inferSelect;
export type NewProductCategory = typeof productCategory.$inferInsert;
export type Product = typeof product.$inferSelect;
export type NewProduct = typeof product.$inferInsert;
// Combined type for Product with its Category details
export type ProductWithCategory = Product & {
categoryDetails: ProductCategory;
};
// Combined type for ProductCategory with its Products
export type ProductCategoryWithProducts = ProductCategory & {
products: Product[];
};
Accessing data via HTTP requests
This lab demonstrates how to build a RESTful API in SvelteKit. Data will be accessed via HTTP requests to various endpoints. The following endpoints are defined:
-
GET /api/v1/products— retrieve all products. -
GET /api/v1/products/:id— retrieve a product by ID. -
POST /api/v1/products— create a new product. -
PUT /api/v1/products/:id— update an existing product. -
DELETE /api/v1/products/:id— delete a product. -
GET /api/v1/categories— retrieve all categories. -
GET /api/v1/categories/:id— retrieve a category by ID.
The HTTP method defines the operation type: GET reads data, POST creates, PUT updates, and DELETE removes. This follows RESTful API design principles.
1. Building the API
API endpoints work similiar to page routes used before but they return data, in this case JSON insteade of complete HTML pages.
They are defined under src\routes\api. Here we have two enpoints for categories and products. The route for each includes an optional parameter, [[id]] , which is used to requerst a single category or product. If this is not included in the URI then all items will be returned in the response.
It is important to maintain compatibility if the endpoint changes so the api version, api/v1, ios included in the routes.
The API endpoints are defined in the +server.ts files. Each endpoint function implments an HTTP method, GET, POST, etc.
The following GET function, defined in src/routes/api/v1/products/[[id]]/+server.ts returns a product or all products as JSON
// src/routes/api/v1/products/[[id]]/+server.ts
import { json } from '@sveltejs/kit';
import { productService } from '$lib/server/services/product-service';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from '../$types';
import type { Product } from '$lib/server/db/schema';
// GET /api/v1/products - Get all products
// get /api/v1/products/:id - Get product by ID
export const GET: RequestHandler = async ({ params }) => {
try {
// Check if an ID parameter is provided
if (params.id && !isNaN(Number(params.id))) {
const productId = Number(params.id);
const product: Product | undefined = await productService.getProductById(productId);
if (product) {
return json(product, { status: 200 });
} else {
throw error(404, 'Product not found');
}
}
// If no ID is provided, return all products
const products = await productService.getAllProducts();
return json(products, { status: 200 });
} catch (err) {
console.error('Error fetching products:', err);
throw error(500, 'Failed to fetch products');
}
};
Exercise: Run the application and test the endpoint in a web browser.
You should see a result similiar to the screenshot.
2. Testing the API
We now have a way to get data via HTTP, without requiring direct access to the database. However, a standard web browser does not make it easy to test API routes other than GET.
To test an API properly, tools such as PostMan and Thunder Client can be used. These tools allow the requiredHTTP method to be selected to test all endpooints.
Thunder Client can be installed as a VS Code extension and will be used in this example.
2.1 Start by getting all products using a GET request for http://localhoost:5173/api/v1/products
Follow the steps below to try this in Thunder Client
2.2 Add a new product using a POST request
Add another new request - otherwise this will overwrite the previous one (in Thunder Client).
This time choose POST and use the same URI as last time. Even though the address is the same, the API will see a HTTP POST (not a GET) and handle it appropriately.
POST requests can include a payload in the request BODY which is used to send JSON data for a new product. Try it.
Exercises
- Use Thunder Client to update existings products via the API.
- Delete a product via the API.
Part 2: Build a client application to use the API
Now that the API is running, buid a client application, using SvelteKit, to use the API as a data source.
Important: Leave the API running and listening on http://localhost:5173
1. Create a new application
Start with an empty folder, e.g. lab12-client-app and create a new SvelteKit application.
Note that drizzle and the database are not required in this example.
1.1 Setup Bootstrap, shared layout, and navigation
Follow previous examples for this. Two page routes are required for the home page and products
1.2 Add a .env for environment variables
This is to avoid hard coding values such as API endpoinfts which may need to be changed
.env
PUBLIC_BASE_API_URL="http://localhost:5173/api/v1/"
PUBLIC_PRODUCTS_ENDPOINT="products"
PUBLIC_CATEGORIES_ENDPOINT="categories"
1.3 Define data objects and types
Define the Product and ProductCategory data objects and types for validation and TypeScript checking.
- Start by installing
zod
npm install zod
-
Add a new file
src\lib\dataModel\index.tsThe content is similar to the server-side, Zod is used to define the objects as validation schema.
// src/lib/dataModel/index.ts
import { z } from 'zod';
// Product
export const Product = z.object({
id: z.number().int().positive(),
category_id: z.number().int().positive(),
thumbnail: z.url().min(7).max(500),
title: z.string().min(1).max(255),
description: z.string().max(1000),
price: z.number().nonnegative(),
stock: z.number().int().min(0),
});
// ProductCategory
export const ProductCategory = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100),
});
// ProductWithCategory (combination of Product and ProductCategory)
export const ProductWithCategory = Product.extend({
categoryDetails: ProductCategory,
});
// CategoryWithProducts (ProductCategory with an array of Products)
export const CategoryWithProducts = ProductCategory.extend({
products: z.array(Product),
});
// Export TypeScript types
export type Product = z.infer<typeof Product>;
export type ProductCategory = z.infer<typeof ProductCategory>;
export type ProductWithCategory = z.infer<typeof ProductWithCategory>;
export type CategoryWithProducts = z.infer<typeof CategoryWithProducts>;
1.4 Define API client functions
These functions will use the fetch API to access the API endpoints.
Create src/lib/api/products.ts
// src/lib/api/products.ts
import type { Product, ProductCategory, ProductWithCategory, CategoryWithProducts } from '$lib/dataModel/index.js';
// Import public (client-side accessible) environment variables for API endpoints
import { PUBLIC_BASE_API_URL, PUBLIC_PRODUCTS_ENDPOINT, PUBLIC_CATEGORIES_ENDPOINT} from '$env/static/public';
// Construct full API URLs
const productsApiUrl = `${PUBLIC_BASE_API_URL}${PUBLIC_PRODUCTS_ENDPOINT}`;
const categoriesApiUrl = `${PUBLIC_BASE_API_URL}${PUBLIC_CATEGORIES_ENDPOINT}`;
// Fetch all products
export async function fetchAllProducts(): Promise<ProductWithCategory[]> {
// Fetch products from the API
const response = await fetch(productsApiUrl);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
// Parse and return the products
const products: ProductWithCategory[] = await response.json();
return products;
}
// Fetch product by ID
export async function fetchProductById(id: number): Promise<ProductWithCategory> {
// Fetch product from the API
const response = await fetch(`${productsApiUrl}/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch product with ID ${id}`);
}
// Parse and return the product
const product: ProductWithCategory = await response.json();
return product;
}
// Fetch all categories
export async function fetchAllCategories(): Promise<ProductCategory[]> {
const response = await fetch(categoriesApiUrl);
if (!response.ok) {
throw new Error('Failed to fetch categories with products');
}
const categories: CategoryWithProducts[] = await response.json();
return categories;
}
// Fetch category by ID
export async function fetchCategoryById(id: number): Promise<CategoryWithProducts> {
const response = await fetch(`${categoriesApiUrl}/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch category with ID ${id}`);
}
const category: CategoryWithProducts = await response.json();
return category;
}
1.5 The products route
This page will display categories and products. It will look like previous examples but work differently.
Add the route and files:
+page.ts runs on server-side as well as in the browser, allowing server-side rendering before display. Add the following code to fetch category and product data for the page:
// src/routes/products/+page.ts
import { fetchAllProducts, fetchAllCategories } from "$lib/api/products";
export async function load() {
const products = await fetchAllProducts();
const categories = await fetchAllCategories();
return { products, categories };
}
The products page, +page.svelte looks very similar except noe data is coming from the API.
Filtering by category also retrieves from the API instead of simply filtering data already loaded. As the asynd call comes from the client-side, the entire page does not need to be refreshed so it will still behave as before.
<script lang="ts">
import type { Product, ProductCategory, ProductWithCategory, CategoryWithProducts } from '$lib/dataModel/index.js';
import { fetchAllProducts, fetchCategoryById } from "$lib/api/products";
// Get data passed from the load function
let { data } = $props();
// State for all products
let products = $state<ProductWithCategory[]>(data.products);
let categories = $state<ProductCategory[]>(data.categories);
// filter products by category
async function handleFilterClick(filter_value: number | null) {
// If no filter is selected, return all products
if (filter_value === null) {
products = await fetchAllProducts();
} else {
// Otherwise, filter products by selected category
let result = await fetchCategoryById(filter_value);
// Update the products state with the filtered products
products = result.products as ProductWithCategory[];
}
}
</script>
<div class="container-fluid">
<div class="row">
<div class="col-lg-2 mb-4">
<div class="sticky-top" style="top: 1rem;">
<h2 class="h5 mb-3">Filters</h2>
<nav aria-label="filter">
<div id="filter-menu" class="list-group mb-3">
<!-- This button shows all products -->
<button
onclick={() => handleFilterClick(null)}
class="list-group-item list-group-item-action">All</button
>
<!-- These buttons filters products by category onclick calls the event handler -->
{#each categories as category}
<button
onclick={() => handleFilterClick(category.id)}
class="list-group-item list-group-item-action">{category.name}</button
>
{/each}
</div>
</nav>
</div>
</div>
<div class="col-lg-10">
<h1>products</h1>
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Picture</th>
<th scope="col">Title</th>
<th scope="col">Category</th>
<th scope="col">Description</th>
<th scope="col">Stock</th>
<th scope="col">Price</th>
</tr>
</thead>
<tbody id="productList">
{#each products as product}
<tr>
<td><img src={product.thumbnail} alt={product.title} class="img-thumbnail" /></td>
<td>{product.title}</td>
<td>{product.categoryDetails?.name || product.category_id}</td>
<td>{product.description}</td>
<td>{product.stock}</td>
<td>{product.price}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
Notes
The current access control implementation based on Lucia and server-side sessions will no longer work as the application api and client applications are on different servers, localhost:5173 and localhost:5174. This will require a different solution, e.g using JSON Web Tokens (JWT).
Exercise
Use a form to add a new product using an API call.
Enda Lee 2025








