-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial ‐ Implementing a Complete Feature
After reading our Project Overview, you might still be confused about how all these pieces fit together. On this page, we're going to walk through implementing a new feature completely, from frontend to backend, using most parts of the stack.
Before starting this tutorial
- You've read through our Project Overview
- You've completed the Dessert Problem for Clark
- this isn't mandatory, but I highly recommend doing this first since it has a more traditional setup with a completely separate frontend and backend, making it easier to understand the data flow; in SvelteKit, you hop between the frontend and backend code a lot since it's a metaframework
- The project is cloned and set up on your computer (see the "Getting Started" wiki page)
The feature we'll be implementing is a Course Search feature, which will let users choose a subject and a class number and get a list of all the professors that teach that class.
In the root directory of our project, you will find a src
directory, where the source code of our web app resides. This is where we'll be spending most of our time.
- Under the
src
directory, we have two directories calledroutes
andlib
.
-
The
routes
directory is where we define the routes of our project. Since SvelteKit uses file-based routing, our routes are determined by the file structure within this directory. -
You can think of routes as different pages or views in your application.
-
For example, when we visit
https://sce.sjsu.edu/
, this is the root route. -
This corresponds to the root of our
routes
directory. The+page.svelte
and+layout.svelte
files in the root of theroutes
directory handle this root route. -
If we navigate to
https://sce.sjsu.edu/about
, this would be the/about
route. -
To replicate this in SvelteKit, we would create a directory under the
routes
directory called/about
, and any+
files (e.g.,+page.svelte
,+layout.svelte
) in that directory would be responsible for rendering the content for the/about
route.
Within each route directory, SvelteKit recognizes files with specific naming conventions to handle different aspects of the route:
File Name | Purpose |
---|---|
+page.svelte |
Contains the page's HTML structure and Svelte components. Defines the main content for the route.
|
+page.ts |
Handles client-side page logic, such as state management, user interactions, and client-side API requests.
|
+page.server.ts |
Handles server-side route logic, such as data fetching, server-side rendering, and form processing.
|
+layout.svelte |
Defines a shared layout that wraps +page.svelte content. Includes common components like header, footer, and navigation. Layouts are inherited by nested routes.
|
+layout.ts |
Contains client-side logic related to the layout, such as managing layout-specific state and handling client-side events.
|
+layout.server.ts |
Handles server-side logic related to the layout, such as fetching layout-required data and performing layout-specific server-side tasks.
|
Warning
It's important to recognize which files can be accessed from the client and the server. Any Svelte files that have .server
in their name run on the server and can only be imported in server-side files. Never include sensitive information, like database access or authentication handling, in code that runs on the client-side.
-
The
lib
directory is where we typically place utility functions, helper modules, and other reusable code that is not directly related to a specific route or page. -
Inside the
lib
directory, we have two subdirectories:components
anddb
. -
The
components
directory is where we put our reusable UI components. These components can be imported and used across different routes or pages in our application. For example, we might have aButton
component or aModal
component that can be used in multiple places throughout the app. -
If we have certain components that are only used in one route, we'll use a similar file structure to our
routes
directory, ex. putting our/about
components undersrc/lib/components/about
-
The
db
directory is where we'll keep code related to our database integration and data access layer. This includes our files for using Drizzle, like our SQL migrations and schemas.
By organizing our codebase in this way, we separate concerns and make it easier to maintain and reuse code. The lib
directory acts as a centralized location for shared utilities, components, and database-related code, while the routes
directory focuses on the application's routes and page-specific logic.
Now that we understand the layout of the repo, we can start implementing the search feature.
First, let's switch to the tutorial-start
branch, which will have a stripped down version of our repo.
git checkout tutorial-start
Note
Don't commit any of your code to this branch, keep all your changes local and discard them at the end
Tip
If you're having trouble at any part of this, feel free to reference the completed code here
We're going to begin with the database schema, then move on to the frontend, and finally tackle the backend. This approach allows us to define the data structure first, which will guide the development of the user interface and the API endpoints.
For our complete database implementation, we need to
- Define our schema
- Generate and push our migrations
- Seed our database
Note
You probably won't have to a do any of this database setup yourself when implementing any features since it'll all be done already, but it's good to learn
First, let's design the schema for our courses. In this tutorial, we'll keep it simple and focus on two tables: the professors table and the courses table.
When thinking about the structure, it's important to consider the relationships between the entities. In this case, we know that a professor can teach multiple courses, and the same course can be taught by multiple professors. This is a classic example of a many-to-many relationship.
To represent this relationship in our schema, we'll need three tables:
- The
professors
table will store information about individual professors, such as their name and department. - The
courses
table will contain details about each course, including the subject, course number, and an optional description. - A join table, which we'll call
professors_courses
, will establish the many-to-many relationship between professors and courses. This table will have two columns:professorId and courseId
, which will reference the primary keys of theprofessors
andcourses
tables, respectively.
Here's a visual representation of the schema
As you can see, the professors_courses
table acts as a bridge between the professors
and courses
tables, allowing us to associate multiple professors with multiple courses.
Now, to implement this into our code, we'll define our schema to model this
// src/lib/db/test-schema.ts
***
import { pgTable, serial, text, integer, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Define the `professors` table with id, name, and department columns.
// - `id` is a serial column used as the primary key.
// - `name` and `department` are text columns that cannot be null.
export const professorsTable = pgTable('professors', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
department: text('department').notNull()
});
// Define the `courses` table with id, subject, courseNumber, and an optional description.
// - `id` is a serial column and primary key.
// - `subject` and `courseNumber` are text columns that cannot be null.
// - `description` is an optional text column.
export const coursesTable = pgTable('courses', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
subject: text('subject').notNull(),
courseNumber: integer('course_number').notNull(),
description: text('description')
});
// Define a join table `professors_courses` to establish a many-to-many relationship
// between `professors` and `courses` through `professorId` and `courseId`.
// Both fields reference the primary keys of their respective tables and cannot be null.
// The combination of `professorId` and `courseId` is used as a composite primary key for this table.
export const professorsCoursesTable = pgTable(
'professors_courses',
{
professorId: integer('professor_id')
.notNull()
.references(() => professorsTable.id), // Reference professor's id
courseId: integer('course_id')
.notNull()
.references(() => coursesTable.id) // Reference course's id
},
(table) => ({
pk: primaryKey({ columns: [table.professorId, table.courseId] }) // Composite primary key
})
);
// Define relationships for the join table. This part of the code specifies
// how records in the `professors_courses` table are related to records in the
// `professors` and `courses` tables, facilitating easier data retrieval and manipulation.
export const professorsCoursesRelations = relations(professorsCoursesTable, ({ one }) => ({
professor: one(professorsTable, {
fields: [professorsCoursesTable.professorId],
references: [professorsTable.id]
}),
course: one(coursesTable, {
fields: [professorsCoursesTable.courseId],
references: [coursesTable.id]
})
}));
Now that we have our schema defined, we need to apply these changes to our local database using Drizzle Kit's migration system.
First, update the drizzle.config.ts
file to use the test schema:
// drizzle.config.ts
// ...
export default {
schema: './src/lib/db/test-schema.ts', // Update this line
// ...
} satisfies Config;
Then, generate the migration files by running:
bun run db:generate
Finally, apply the migrations to your local database:
bun run db:migrate
This process ensures that your local database schema is in sync with your codebase. With the migrations applied, you're ready to seed the database with some test data.
Warning
When trying to delete migrations, never delete the files themselves as this will mess with drizzle. You should instead run
bun run db:drop
With the database set up, it's time to populate it with placeholder data to simulate a real-world scenario. This process is known as seeding.
Create a seeding script src/lib/db/seed.ts
with the following code:
import { db } from './db.server';
import { professorsTable, coursesTable, professorsCoursesTable } from './schema';
const main = async () => {
try {
console.log('Seeding database');
await db.delete(professorsCoursesTable);
await db.delete(professorsTable);
await db.delete(coursesTable);
console.log('Inserting data');
const professors = [
{ id: 1, name: 'John Smith', department: 'Mathematics' },
{ id: 2, name: 'Jane Doe', department: 'Science' },
{ id: 3, name: 'Bob Johnson', department: 'History' },
{ id: 4, name: 'Alice Williams', department: 'Mathematics' },
{ id: 5, name: 'Charlie Brown', department: 'Science' },
{ id: 6, name: 'David Davis', department: 'History' },
{ id: 7, name: 'Eva Green', department: 'Computer Science' },
{ id: 8, name: 'Michael Brown', department: 'Engineering' }
];
const courses = [
{ id: 1, title: 'Calculus', subject: 'MATH', courseNumber: 106, description: 'An introductory course to calculus' },
{ id: 2, title: 'Biology', subject: 'SCI', courseNumber: 105, description: 'An introductory course to biology' },
{ id: 3, title: 'World War II', subject: 'HIST', courseNumber: 101, description: 'A course about World War II' },
{ id: 4, title: 'Algebra', subject: 'MATH', courseNumber: 102, description: 'An introductory course to algebra' },
{ id: 5, title: 'Chemistry', subject: 'SCI', courseNumber: 113, description: 'An introductory course to chemistry' },
{ id: 6, title: 'World War I', subject: 'HIST', courseNumber: 121, description: 'A course about World War I' },
{ id: 7, title: 'Advanced Calculus', subject: 'MATH', courseNumber: 201, description: 'An advanced course in calculus' },
{ id: 8, title: 'Advanced Biology', subject: 'SCI', courseNumber: 241, description: 'An advanced course in biology' },
{ id: 9, title: 'The Civil War', subject: 'HIST', courseNumber: 220, description: 'A course about The Civil War' },
{ id: 10, title: 'Introduction to Programming', subject: 'COMPSCI', courseNumber: 101, description: 'An introductory course to programming' },
{ id: 11, title: 'Engineering Principles', subject: 'ENG', courseNumber: 102, description: 'Fundamentals of engineering' }
];
const professorsCourses = [
{ professorId: 1, courseId: 1 },
{ professorId: 1, courseId: 4 },
{ professorId: 1, courseId: 7 },
{ professorId: 2, courseId: 2 },
{ professorId: 2, courseId: 5 },
{ professorId: 2, courseId: 8 },
{ professorId: 3, courseId: 3 },
{ professorId: 3, courseId: 6 },
{ professorId: 3, courseId: 9 },
{ professorId: 4, courseId: 1 },
{ professorId: 4, courseId: 4 },
{ professorId: 4, courseId: 7 },
{ professorId: 5, courseId: 2 },
{ professorId: 5, courseId: 5 },
{ professorId: 5, courseId: 8 },
{ professorId: 7, courseId: 10 }, // COMPSCI professor teaching COMPSCI course
{ professorId: 8, courseId: 11 } // Engineering professor teaching ENG course
];
await db.insert(professorsTable).values(professors);
await db.insert(coursesTable).values(courses);
await db.insert(professorsCoursesTable).values(professorsCourses);
console.log('Database seeded, press Ctrl+C to exit');
} catch (error) {
console.error(error);
throw new Error('Error seeding database');
}
};
main();
This script first deletes all existing data from the tables, then inserts placeholder data into the professors
, courses
, and professorsCoursesTable
tables.
Now run the script using
bun run db:seed
To verify the seeding process, use Drizzle Studio:
bun run db:studio
Visit https://local.drizzle.studio/ to view the tables and their data.
With the seeded data in place, the database setup is complete, and we can move on to implementing the search
For this tutorial, we'll be using the /test
route. Create a +page.svelte
and a +page.server.ts
under src/routes/test/+page.svelte
and visit localhost:5173/test
while your dev server is running to see the changes locally. Whenever you save your files, your browser will hot reload to reflect the changes.
Before diving into the implementation, let's understand the structure of a Svelte component:
<script lang="ts">
// TypeScript code goes here
let name = $state("John")
</script>
<!-- Svelte markup goes here -->
<main>
<h1 class="text-3xl font-bold">Hello, {name}!</h1>
<p class="text-lg">Welcome to our website.</p>
</main>
<style>
/* CSS styles go here */
/* But we'll mostly use inline styles with Tailwind CSS */
</style>
A Svelte component consists of three main sections:
-
<script>
Tag: This is where we write our TypeScript code. Thelang="ts"
attribute tells Svelte that we're using TypeScript. -
Svelte Markup: This is where we write our Svelte markup, which is similar to HTML but with additional templating features. We can use curly braces
{}
to embed JavaScript expressions in our markup. -
<style>
Tag: This is where we write CSS styles for the component. However, in this project, we'll primarily use inline styles with Tailwind CSS.
Before creating our component, let's define the requirements for the search functionality:
-
Class Selector: Display a selector that lists all the classes available in our database, allowing users to select the class they want to search for professors.
-
Submit Button: Include a button that triggers the search when clicked, initiating the process of finding professors who teach the selected class.
-
Display Professors: Pass the professors who teach the selected class to another component for display. This separate component will be responsible for rendering the list of professors.
To populate the class selector, we need to fetch all the classes from our database. We can use a +page.server.ts
file to fetch data on the server and pass it to our page.
To get started, we first want to create a load
function in our +page.server.ts
file:
// src/routes/test/+page.server.ts
export const load = async () => {
// Our code to fetch data will go here
}
- The
load
function runs when the page loads (technically, it runs before the page is rendered on the server). This function allows us to fetch data that we can then use in our component right away. In this case, we want to fetch data from the database.
To fetch data from the database, we want to use the db
object and run queries on it, similar to how we did in our seeding file.
import { db } from '$lib/db/db.server';
import { coursesTable } from '$lib/db/schema';
import { asc } from 'drizzle-orm'
export const load = async () => {
const result = await db
.select({
id: coursesTable.id,
title:
subject: coursesTable.subject,
courseNumber: coursesTable.courseNumber,
description: coursesTable.description
})
.from(coursesTable);
.orderBy(asc(coursesTable.subject));
return {
courseData: result
}
};
- This function selects the
id
,subject
,anddescription
columns from ourcoursesTable
and returns the result.
Now that we have fetched the course data in our load
function, we need to access this data within our +page.svelte
component, which is located in the same directory as the +page.server.ts
file.
- To access the data returned from the
load
function, we can use thedata
prop in our Svelte component:
<script lang="ts">
let { data } = $props();
let courses = $derived(data.courseData);
</script>
- The
data
prop contains the data returned from the+page.server.ts
file. Since we're returning an array of course objects from theload
function,data.classData
will be that array.
Note
SvelteKit provides type safety and autocompletion for the data
prop, so hovering over it in your editor will show the typing of the courses.
Now that we have the course data available in our page, let's pass it down to a Search component.
- Create a new component file
src/lib/components/search/Search.svelte
:
<!-- src/lib/components/search/Search.svelte -->
<script lang="ts">
let { courses } = $props();
</script>
- To pass the data from
+page.svelte
to the Search component, we need to accept a prop in the Search component and pass the courses data when importing it in+page.svelte
.
<!-- src/routes/test/+page.svelte -->
<script lang="ts">
import Search from '$lib/components/test/Search.svelte';
let { data } = $props();
let courses = $derived(data.classData);
</script>
<Search courses={courses} />
Now that we have access to all the courses in our Search component, we can utilize them to create the search functionality.
In this example, we'll implement two selectors: one for subjects and another for course numbers.
- Initially, only the subject selector will be available, while the course number selector will be disabled.
- Once a subject is selected, the course number selector will be populated with the corresponding course numbers for that subject and become enabled.
- When we submit our form, we'll use the selected courses' id to find corresponding professors.
Let's break down how this component will be set up, starting with the script tag
- First, in our script tag, we need to define the type of our courses. We'll use a typescript interface for this
interface Course { id: number; title: string; subject: string; courseNumber: number; }
- Then, we need to modify our courses prop to use these types. We can use this by passing our type structure as a generic to the
$props()
rune. Sincecourses
will be an array ofCourse
, we will define it as aCourse[]
let { courses }: { courses: Course[] } = $props();
- To keep track of our selected subject and selected course number, we'll define them using the
$state()
rune that can either be string or undefined, and initializes as undefined, since the selectors should be clear when the component initializeslet selectedSubject = $state<string | undefined>(undefined); let selectedCourseNumber = $state<number | undefined>(undefined);
- Now in order to dynamically get our course numbers based on what the selected subject is. We can use the
$derived()
rune to.filter()
the courses array to include only the courses where the course subject is equal to the subject we selected. We then.map()
this filtered array to a new array containing only the course number and the title of the course we selected.let courseNumbers = $derived( courses .filter((course) => course.subject === selectedSubject) .map((course) => ({ number: course.courseNumber, title: course.title })) );
- We'll use another
.derived()
variable to.find()
the selected courses' idlet selectedCourseId = $derived( courses.find( (course) => course.subject === selectedSubject && course.courseNumber === selectedCourseNumber )?.id );
- For our actual markup, we first want to create a
<form />
tag with thesearch
action, since we'll be using Sveltekit's form actions to handle this component. We'll also use a few tailwind classes to add some spacing between them. We'll also use Sveltekit'senhance
to make this form more user friendly, in this case it'll just make it so the page doesn't clear the inputs when submitting the form<script lang="ts"> import { enhance } from '$app/forms'; // ... </script> <form method="POST" action="?/search" class="flex items-center space-x-4" use:enhance={() => { return async ({ update }) => { update({ reset: false }); }; }} > </form>
- For our first select component, we're going to have a
<select />
tag and bind ourselectedSubject
variable to its value. For our<options />
inside of it, we'll have a placeholder option telling the user to select a subject. For our other options, we'll.map()
over our courses and create a new array of the subjects, and insert it into aSet()
so only one of each subject is shown. Then we'll Svelte's{#each}
block to create an option for each subject.<select bind:value={selectedSubject} class="select"> <option value="">Select a subject</option> {#each [...new Set(courses.map((course) => course.subject))] as subject} <option value={subject}>{subject}</option> {/each} </select>
- We'll do the same thing for our course number
<select />
, but set ourdisabled
attribute to disable it unless a subject is selected.<select bind:value={selectedCourseNumber} class="select" disabled={!selectedSubject}> <option value="">Select a course number</option> {#each courseNumbers as { number, title }} <option value={number}>{number} - {title}</option> {/each} </select>
- Since we only want the course id to be submitted to our backend, we'll add a hidden form input bound to it
{#if selectedCourseId} <input type="hidden" name="courseId" value={selectedCourseId} /> {/if}
- And finally, we'll make a button to submit our form that's also disabled unless a course is selected.
<button class="btn" type="submit" disabled={!selectedCourseNumber}>Search</button>
Putting this all together, we get this
<!-- src/lib/components/test/Search.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
interface Course {
id: number,
title: string;
courseNumber: number;
subject: string;
}
let { courses }: { courses: Course[] } = $props();
let selectedSubject = $state<string | undefined>(undefined);
let selectedCourseNumber = $state<number | undefined>(undefined);
let courseNumbers = $derived(
courses
.filter((course) => course.subject === selectedSubject)
.map((course) => ({ number: course.courseNumber, title: course.title }))
);
let selectedCourseId = $derived(
courses.find(
(course) => course.subject === selectedSubject && course.courseNumber === selectedCourseNumber
)?.id
);
</script>
<form
method="POST"
action="?/search"
class="flex items-center space-x-4"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
>
<select bind:value={selectedSubject} class="select">
<option value="">Select a subject</option>
{#each [...new Set(courses.map((course) => course.subject))] as subject}
<option value={subject}>{subject}</option>
{/each}
</select>
<select bind:value={selectedCourseNumber} class="select" disabled={!selectedSubject}>
<option value="">Select a course number</option>
{#each courseNumbers as { number, title }}
<option value={number}>{number} - {title}</option>
{/each}
</select>
{#if selectedCourseId}
<input type="hidden" name="courseId" value={selectedCourseId} />
{/if}
<button class="btn" type="submit" disabled={!selectedCourseNumber}>Search</button>
</form>
Now that we have our search component set up, we'll create our form action in our +page.server.ts
to handle it.
- We first need to create the
search
action and get thecourseId
from the form// src/routes/test/+page.server.ts export const actions = { search: async ({ request }) => { const data = await request.formData(); const courseId = Number(data.get('courseId')); }
- Then we need to get to get all the professors' ids that teach that course from the
professorsCoursesTable
. Since this will give us an array of objects with ids, we'll need to map this into a new array of just the ids.import { db } from '$lib/db/db.server'; import { professorsCoursesTable } from '$lib/db/schema'; import { eq } from 'drizzle-orm'; // ... const professorsIds = await db .select({ id: professorsCoursesTable.professorId }) .from(professorsCoursesTable) .where(eq(professorsCoursesTable.courseId, courseId)); const ids = professorsIds.map((professor) => professor.id);
- Finally, we can use this array of ids to return all the professors that teach the course in alphabetical order.
import { professorsCoursesTable, professorsTable } from '$lib/db/schema'; import { eq, inArray } from 'drizzle-orm'; // ... const professors = await db .select({ id: professorsTable.id, name: professorsTable.name, department: professorsTable.department }) .from(professorsTable) .orderBy(asc(professorsTable.name)) .where(inArray(professorsTable.id, ids)); return { professors };
Now that we have this data, we'll display it in a table.
- In our
+page.svelte
, we'll update our props so we also deconstruct the form, which will have the data returned from our form action.let { data, form } = $props();
- We'll then create another component called Table that has the
professors
prop in the same way we created the Search component earlier and import it. Then we'll pass theprofessors
object into itsrc/lib/components/test/Table.svelte <script lang="ts"> interface Professor { id: number; name: string; department: string; } let { professors }: { professors: Professor[] } = $props(); </script> <div class="overflow-x-auto"> <table class="table"> <!-- head --> <thead> <tr> <th></th> <th>Name</th> <th>Department</th> </tr> </thead> <tbody> {#each professors as professor, i} <tr> <th>{i + 1}</th> <td>{professor.name}</td> <td>{professor.department}</td> </tr> {/each} </tbody> </table> </div>
src/routes/test/+page.svelte <script lang="ts"> import Search from '$lib/components/test/Search.svelte'; import Table from '$lib/components/test/Table.svelte'; let { data, form } = $props(); let courses = $derived(data.classData); </script> <Search {courses} /> {#if form?.professors} <Table professors={form.professors} /> {/if}
If everything works properly, you should get something like this
Congrats! You successfully implemented a complete feature from scratch.
For practice, let's format our code in consistent styles. You'll want to do this before committing your changes once we actually start implementing features.
bun run format
You can now clear your working repository
git reset --hard HEAD
Now that you have a general idea on how to implement features from scratch, move on to Contributing