Build a Link Board with TanStack Start
Build a full-stack bookmark app with type-safe routing and server functions.
Introduction
30 Second Summary
You probably have bookmarks scattered across browsers, notes apps, and random tabs. What if you could build your own tool to organize them exactly the way you want?
In this project, you will build a Link Board app using TanStack Start, a full-stack React framework with type-safe routing, server functions, and validated data flow.
What You'll Build
A full-stack Link Board app with three pages for saving, browsing, filtering, and deleting links, powered by TanStack Start's file-based routing and server functions with a local JSON file for persistence.
By the end of this project, you'll have:
- A TanStack Start project scaffolded from the official CLI with Tailwind CSS styling.
- Server functions using createServerFn that read and write links to a local JSON file with Zod validation.
- Type-safe search params for filtering links by tag and a dynamic route for viewing individual link details.
- A Secret Mission to add an edit page for updating existing links.
Are there any prerequisites?
You should be familiar with React, TypeScript, and basic terminal usage. You will also need Node.js 20+ and Cursor installed on your machine.
Scaffold the TanStack Start Project
Every full-stack app starts with a solid foundation. In this project, you're building a Link Board app that saves, browses, filters, and deletes bookmarks. It runs on TanStack Start, a full-stack React framework powered by Vite and TanStack Router.
Before you can build anything, you need a working project. The TanStack CLI scaffolds a ready-to-run app with routing, TypeScript, and Tailwind CSS pre-configured. In this step, you will scaffold the project, explore the generated files, and confirm everything runs.
In this step, get ready to:
- Scaffold a new TanStack Start project using the CLI.
- Explore the generated file structure in Cursor.
- Start the development server and verify the app loads.
Run the TanStack CLI to create a new project
The TanStack CLI is the official tool for scaffolding TanStack Start apps. It generates a complete project with routing, TypeScript, and Tailwind CSS already wired up.
- Open your terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your project by running this command:
npx @tanstack/cli@latest create link-board
What does this command do?
- npx @tanstack/cli@latest downloads and runs the latest version of the TanStack CLI without installing it globally.
- create link-board tells the CLI to scaffold a new project in a folder called link-board.
The CLI will ask you a few interactive questions. Answer each one as follows:
- For the package manager prompt, select npm.
- For the toolchain prompt, select None.
- For the git initialization prompt, select Yes.
What about Tailwind CSS?
Tailwind CSS is always included in TanStack Start projects. The CLI enables it automatically regardless of what you select, so you do not need to configure it separately.
The CLI will install your dependencies automatically. Once it finishes, you will see a success message.
- Move into your new project folder by running:
cd link-board
- Confirm you are inside the project by running:
ls
You should see files like package.json, vite.config.ts, tsconfig.json, and a src folder in the output.
Command not found?
- Make sure you have Node.js 20 or later installed. Run node --version to check.
- If npx is not recognized, reinstall Node.js from nodejs.org.
- help me fix the TanStack CLI command
Open the project in Cursor and explore the file structure
The CLI generated a complete project with routing, configuration, and a default home page. Before writing any code, take a moment to understand the files you are working with.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the link-board folder on your Desktop, then click Open.
In the Explorer sidebar on the left, expand the src folder. Here are the key files to know about:
- src/routes/__root.tsx is the root layout. Every page in your app renders inside this file. It defines the HTML shell, meta tags, and shared UI like navigation.
- src/routes/index.tsx is the home page route. It maps to / in the browser. You will replace this with your Link Board in a later step.
- src/router.tsx is the router configuration. It creates the router instance using the auto-generated route tree.
- vite.config.ts configures Vite with the TanStack Start plugin. This file wires up server-side rendering, the dev server on port 3000, and the Tailwind CSS plugin.
What is file-based routing?
TanStack Router uses file-based routing. Every file you create inside src/routes/ automatically becomes a route in your app. For example, src/routes/add.tsx becomes the /add page. No manual route registration needed.
What is routeTree.gen.ts?
You may notice a file called routeTree.gen.ts in the src folder. This file is auto-generated by TanStack Router when the dev server starts. It contains a map of all your routes. You never edit this file directly.
Start the development server
With the project scaffolded and opened in Cursor, it is time to see it running in the browser.
- Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default TanStack Start welcome page with a counter button. This confirms your project is scaffolded correctly and the dev server is running.
Page not loading?
- Check your terminal for error messages. A common issue is another process already using port 3000. Close any other dev servers and try again.
- Make sure you ran npm run dev from inside the link-board folder. Run pwd to check your current directory.
- If dependencies are missing, run npm install and then npm run dev again.
- help me get the TanStack Start dev server running
Your project is scaffolded and running. Next up, you will build the data layer with server functions that read and write links to a local JSON file.
Build the Data Layer with Server Functions
Your TanStack Start project is scaffolded and running. Now it needs some real functionality behind it.
Every link you save needs a place to live, a shape to follow, and a set of operations to manage it. In this step, you'll build all three: a validation schema, a file-based storage layer, and server functions that tie everything together.
In this step, get ready to:
- Define a shared link schema and types with Zod.
- Build file storage helpers to read and write links to a local JSON file.
- Create server functions for fetching, creating, and deleting links.
Define the link schema and types
Before writing any server-side logic, you need a shared definition of what a "link" looks like. This definition will be used on both the server (for validation) and the client (for type safety).
- In Cursor's Explorer sidebar, right-click on the src folder.
- Select New Folder and name it utils.
- Right-click on the new utils folder and select New File.
- Name the file schemas.ts.
- Paste the following code into schemas.ts:
import { z } from 'zod'
// Define validation rules for new link input
export const linkSchema = z.object({
title: z.string().min(1, 'Title is required'),
url: z.string().url('Must be a valid URL'),
description: z.string().optional().default(''),
tags: z.array(z.string()).optional().default([]),
})
// Derive a TypeScript type from the Zod schema
export type LinkInput = z.infer<typeof linkSchema>
// Full link type adds server-generated fields
export type Link = LinkInput & {
id: string
createdAt: string
}
What does this code do?
- linkSchema defines the shape and validation rules for link input. Title and URL are required. Description and tags are optional with safe defaults.
- LinkInput is a TypeScript type automatically derived from the Zod schema using z.infer. Your types and validation rules always stay in sync.
- Link extends LinkInput with two server-generated fields: id and createdAt. These are added on the server when a link is created, not by the user.
- Save schemas.ts.
- You should see schemas.ts inside the utils folder in the Explorer sidebar.
Seeing red squiggles in Cursor?
- Make sure zod is installed. The TanStack CLI should have included it. Run npm list zod in your terminal to check.
- If zod is missing, install it by running npm install zod in your terminal.
- Why is Zod showing import errors in my schemas.ts file?
Create the file storage helpers
Your links need a place to live between server restarts. Instead of setting up a database, you'll use a local JSON file for storage. This keeps the project focused on TanStack Start concepts.
- Right-click on the utils folder in the Explorer sidebar and select New File.
- Name the file links.server.ts.
- Paste the following code into links.server.ts:
import * as fs from 'node:fs'
import * as path from 'node:path'
import type { Link } from './schemas'
// Path to the JSON file where links are stored
const filePath = path.resolve('links.json')
// Read all links from the JSON file
export async function readLinks(): Promise<Link[]> {
try {
const data = await fs.promises.readFile(filePath, 'utf-8')
return JSON.parse(data)
} catch {
// Return empty array if the file doesn't exist yet
return []
}
}
// Write the full links array to the JSON file
export async function writeLinks(links: Link[]): Promise<void> {
await fs.promises.writeFile(filePath, JSON.stringify(links, null, 2))
}
What does this code do?
- filePath points to a links.json file at the project root. This file gets created automatically the first time you save a link.
- readLinks() reads the JSON file and parses it into an array of Link objects. If the file doesn't exist yet, it catches the error and returns an empty array instead of crashing.
- writeLinks() takes the full array of links and writes it back to the JSON file with pretty formatting.
Why is the file named .server.ts?
The .server.ts suffix tells TanStack Start that this file contains server-only code. It will never be included in the client bundle sent to the browser.
This matters because Node.js modules like fs and path don't exist in the browser. Without the suffix, bundling this file for the client would cause errors.
- Save links.server.ts.
- You should see both schemas.ts and links.server.ts inside the utils folder in the Explorer sidebar.
Seeing an import error for ./schemas?
- Check that schemas.ts is in the same utils folder as links.server.ts.
- Make sure you saved schemas.ts first. Unsaved files can't be resolved by the TypeScript compiler.
- Why can't links.server.ts find the schemas import?
Create the server functions
Server functions are how TanStack Start bridges client and server code. They look like regular async functions, but they run exclusively on the server.
Your client components can call them directly without you writing any API routes. TanStack Start handles the network boundary for you.
- Right-click on the utils folder in the Explorer sidebar and select New File.
- Name the file links.functions.ts.
- Paste the following code into links.functions.ts to set up the imports and create the first two server functions:
import { createServerFn } from '@tanstack/react-start'
import { linkSchema } from './schemas'
import { readLinks, writeLinks } from './links.server'
// Fetch all links from storage
export const getLinks = createServerFn({ method: 'GET' }).handler(async () => {
return readLinks()
})
// Validate input with Zod, then create and save a new link
export const createLink = createServerFn({ method: 'POST' })
.inputValidator(linkSchema)
.handler(async ({ data }) => {
const links = await readLinks()
// Add server-generated fields
const newLink = {
...data,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
}
links.push(newLink)
await writeLinks(links)
return newLink
})
What does this code do?
- getLinks is a GET server function that reads all links from the JSON file. Routes will call this in their loaders to fetch data before rendering a page.
- createLink is a POST server function that validates input using your linkSchema, generates a unique ID with crypto.randomUUID(), and saves the new link to storage.
- .inputValidator(linkSchema) means any invalid data gets rejected before the handler function even runs. Zod does the heavy lifting here.
- createServerFn({ method: 'GET' }) vs createServerFn({ method: 'POST' }) tells TanStack Start whether the function reads data or modifies it.
- Save links.functions.ts.
You won't see any changes in the browser yet. These functions only run when a route calls them, which you'll set up in the next step.
- Add the deleteLink server function at the bottom of links.functions.ts, below the createLink function:
// Remove a link by its ID
export const deleteLink = createServerFn({ method: 'POST' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const links = await readLinks()
// Keep every link except the one being deleted
const filtered = links.filter((l) => l.id !== data.id)
await writeLinks(filtered)
return { success: true }
})
What does this code do?
- deleteLink is a POST server function that removes a link by its id. It reads all links, filters out the matching one, and writes the updated array back to the file.
- .inputValidator((data: { id: string }) => data) uses a simple inline validator instead of a Zod schema. This is fine for inputs that only need a type check, not complex validation rules.
- Save links.functions.ts.
Your data layer is complete. Three server functions handle all the link operations your app needs.
Seeing import errors in links.functions.ts?
- Check that all three files (schemas.ts, links.server.ts, links.functions.ts) are in the src/utils folder.
- Make sure the file names match exactly. links.server.ts and links.functions.ts use dots, not hyphens.
- Verify that @tanstack/react-start is installed by checking your package.json dependencies. The CLI should have installed it for you.
- Why are my server function imports failing in links.functions.ts?
Awesome, I've got everything!
Great. Double check you've saved all three files in the utils folder.
I'd like to double check the full code
Here is the complete links.functions.ts file with all three server functions:
import { createServerFn } from '@tanstack/react-start'
import { linkSchema } from './schemas'
import { readLinks, writeLinks } from './links.server'
// Fetch all links from storage
export const getLinks = createServerFn({ method: 'GET' }).handler(async () => {
return readLinks()
})
// Validate input with Zod, then create and save a new link
export const createLink = createServerFn({ method: 'POST' })
.inputValidator(linkSchema)
.handler(async ({ data }) => {
const links = await readLinks()
// Add server-generated fields
const newLink = {
...data,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
}
links.push(newLink)
await writeLinks(links)
return newLink
})
// Remove a link by its ID
export const deleteLink = createServerFn({ method: 'POST' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const links = await readLinks()
// Keep every link except the one being deleted
const filtered = links.filter((l) => l.id !== data.id)
await writeLinks(filtered)
return { success: true }
})
Your data layer is locked and loaded. Next up, you'll build the main board page that calls these server functions to display, filter, and delete links.
Build the Main Board with Search Param Filtering
Your data layer is ready with server functions that can read, create, and delete links. Now you need a UI that calls those functions and displays the results.
In this step, you will build the root layout with shared navigation and the main board page. The board uses TanStack Router's type-safe search params to filter links by tag, so the URL always reflects what the user sees.
In this step, get ready to:
- Add a shared navigation bar to the root layout.
- Build the main board page with tag-based search param filtering.
- Add delete functionality to each link card.
Add a shared navigation bar to the root layout
Every page in your app shares the same navigation bar. The root layout file src/routes/__root.tsx wraps every route with shared UI like headers, footers, and navigation links.
- In Cursor, open src/routes/__root.tsx.
- Select all the existing content and replace it with this code to set up the route configuration and page metadata:
/// <reference types="vite/client" />
import type { ReactNode } from 'react'
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
Link,
} from '@tanstack/react-router'
// Configure the root route with page metadata
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Link Board' },
],
links: [
{
rel: 'stylesheet',
href: '/src/styles.css',
},
],
}),
component: RootComponent,
})
What does this code do?
- The /// <reference types="vite/client" /> directive gives TypeScript access to Vite-specific type definitions.
- createRootRoute defines the top-level route that wraps every page in your app.
- The head function returns metadata for the HTML <head> tag, including the page title and the Tailwind CSS stylesheet.
- The Link import is TanStack Router's navigation component. It provides type-safe routing between pages.
You won't see a change yet. The RootComponent function referenced in the config still needs to be defined.
- Below the Route definition, add the RootComponent and RootDocument functions:
function RootComponent() {
return (
<RootDocument>
<div className="min-h-screen bg-gray-50">
{/* Navigation bar shared across all pages */}
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center gap-6">
<Link to="/" className="text-lg font-bold text-gray-900">
Link Board
</Link>
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
activeProps={{ className: 'text-sm text-blue-600 font-medium' }}
>
Board
</Link>
<Link
to="/add"
className="text-sm text-gray-600 hover:text-gray-900"
activeProps={{ className: 'text-sm text-blue-600 font-medium' }}
>
Add Link
</Link>
</div>
</nav>
{/* Outlet renders whatever child route is active */}
<main className="max-w-4xl mx-auto px-4 py-8">
<Outlet />
</main>
</div>
</RootDocument>
)
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
What does this code do?
- RootComponent renders a navigation bar with three Link components pointing to / (home) and /add (add link page).
- activeProps changes the styling of each navigation link when its route is active, giving users a visual indicator of which page they are on.
- Outlet is a placeholder that renders the current child route. Every page you build will appear inside this Outlet.
- RootDocument wraps everything in the standard HTML document structure, including HeadContent (metadata) and Scripts (client-side JavaScript).
- Save src/routes/__root.tsx.
- Refresh your browser at http://localhost:3000. You should see a white navigation bar at the top with "Link Board", "Board", and "Add Link" links.
Don't see the navigation bar?
- Make sure your dev server is still running. If you stopped it, run npm run dev again in your terminal.
- Check that you replaced ALL of the original content in __root.tsx. Leftover code from the CLI scaffold can cause conflicts.
- help me debug my root layout navigation bar
Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
I'd like to double check the full code
/// <reference types="vite/client" />
import type { ReactNode } from 'react'
import {
Outlet,
createRootRoute,
HeadContent,
Scripts,
Link,
} from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Link Board' },
],
links: [
{
rel: 'stylesheet',
href: '/src/styles.css',
},
],
}),
component: RootComponent,
})
function RootComponent() {
return (
<RootDocument>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center gap-6">
<Link to="/" className="text-lg font-bold text-gray-900">
Link Board
</Link>
<Link
to="/"
className="text-sm text-gray-600 hover:text-gray-900"
activeProps={{ className: 'text-sm text-blue-600 font-medium' }}
>
Board
</Link>
<Link
to="/add"
className="text-sm text-gray-600 hover:text-gray-900"
activeProps={{ className: 'text-sm text-blue-600 font-medium' }}
>
Add Link
</Link>
</div>
</nav>
<main className="max-w-4xl mx-auto px-4 py-8">
<Outlet />
</main>
</div>
</RootDocument>
)
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
Build the main board page with tag filtering
The index route is the main board page that lists all saved links. TanStack Router lets you define type-safe search params using a Zod schema, so filtering by tag becomes a first-class part of the URL.
When a user clicks a tag filter button, the URL updates to /?tag=react and the loader re-runs with the new tag. No manual state management needed.
- Open src/routes/index.tsx.
- Select all the existing content and replace it with the route configuration and search param schema:
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { getLinks } from '../utils/links.functions'
// Define a Zod schema for the search params
const searchSchema = z.object({
tag: z.string().optional().catch(undefined),
})
// Configure the index route with search param validation and a data loader
export const Route = createFileRoute('/')( {
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({ tag: search.tag }),
loader: async ({ deps }) => {
const links = await getLinks()
if (deps.tag) {
return links.filter((link) => link.tags.includes(deps.tag!))
}
return links
},
component: BoardPage,
})
What does this code do?
- searchSchema defines the shape of the URL search params. The tag param is optional and uses .catch(undefined) so invalid values fall back silently instead of throwing an error.
- validateSearch tells TanStack Router to parse and validate the URL search params using that Zod schema. With Zod v4, you pass the schema directly without needing an adapter.
- loaderDeps declares that the loader depends on the tag search param. Whenever tag changes in the URL, the loader automatically re-runs.
- The loader calls the getLinks server function and filters the results by tag if one is present.
You won't see a change yet. The BoardPage component referenced in the route config still needs to be defined.
- Below the Route definition, add the BoardPage component that displays links and tag filter buttons:
function BoardPage() {
// Access the loader data and search params
const links = Route.useLoaderData()
const { tag } = Route.useSearch()
// Collect all unique tags from the loaded links
const allTags = [...new Set(links.flatMap((link) => link.tags))]
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{tag ? `Links tagged "${tag}"` : 'All Links'}
</h1>
<Link
to="/add"
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700"
>
+ Add Link
</Link>
</div>
{/* Tag filter buttons */}
{allTags.length > 0 && (
<div className="flex gap-2 mb-6 flex-wrap">
<Link
to="/"
search={{}}
className={`px-3 py-1 rounded-full text-sm ${
!tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</Link>
{allTags.map((t) => (
<Link
key={t}
to="/"
search={{ tag: t }}
className={`px-3 py-1 rounded-full text-sm ${
tag === t
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{t}
</Link>
))}
</div>
)}
{/* Link cards grid */}
{links.length === 0 ? (
<p className="text-gray-500 text-center py-12">
No links yet. Add your first one!
</p>
) : (
<div className="grid gap-4">
{links.map((link) => (
<div
key={link.id}
className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"
>
<Link
to="/links/$linkId"
params={{ linkId: link.id }}
className="text-lg font-medium text-blue-600 hover:underline"
>
{link.title}
</Link>
<p className="text-sm text-gray-500 mt-1">{link.url}</p>
{link.description && (
<p className="text-sm text-gray-700 mt-2">
{link.description}
</p>
)}
{link.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{link.tags.map((t) => (
<span
key={t}
className="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded"
>
{t}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
What does this code do?
- Route.useLoaderData() returns the typed data from the loader. Route.useSearch() returns the validated search params.
- allTags collects every unique tag from the loaded links using a Set. These tags become the filter buttons.
- Each tag filter button is a Link component with a search prop. Clicking it updates the URL (e.g. /?tag=react) which triggers the loader to re-fetch and filter.
- Each link card navigates to /links/$linkId using dynamic params. You will build that detail page in a later step.
- Save src/routes/index.tsx.
- Refresh your browser at http://localhost:3000. You should see the heading "All Links" with a blue "+ Add Link" button and the message "No links yet. Add your first one!" since the JSON file is empty.
Seeing a blank page or error?
- Check your terminal for errors. A common issue is a missing import. Make sure you imported createFileRoute and Link from @tanstack/react-router and getLinks from ../utils/links.functions.
- If you see a Zod-related error, make sure you have z imported from zod and that searchSchema is defined above the Route export.
- help me debug my TanStack Start index route
Add a delete button to each link card
Users need a way to remove links from the board. To call a server function from a component, you use the useServerFn hook from TanStack Start. After deleting, you call router.invalidate() to re-run all active loaders so the board refreshes with the updated data.
- In src/routes/index.tsx, find the imports at the top of the file:
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { getLinks } from '../utils/links.functions'
- Replace those three import lines with these four lines to add useRouter, useServerFn, and deleteLink:
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { getLinks, deleteLink } from '../utils/links.functions'
- Inside the BoardPage function, find these two lines near the top:
const links = Route.useLoaderData()
const { tag } = Route.useSearch()
- Add two new lines directly below them to set up the router and delete handler:
const links = Route.useLoaderData()
const { tag } = Route.useSearch()
const router = useRouter()
const handleDelete = useServerFn(deleteLink)
Now you need to update each link card to include a delete button. The card's content needs to be wrapped in a flex container so the button sits on the right side.
- Inside the links.map() callback, find the card <div> that starts with key={link.id}.
- Replace the entire card <div> and its contents with this updated version that adds a flex layout and delete button:
<div
key={link.id}
className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"
>
{/* Flex layout to position card content and delete button */}
<div className="flex items-start justify-between">
<div>
<Link
to="/links/$linkId"
params={{ linkId: link.id }}
className="text-lg font-medium text-blue-600 hover:underline"
>
{link.title}
</Link>
<p className="text-sm text-gray-500 mt-1">{link.url}</p>
{link.description && (
<p className="text-sm text-gray-700 mt-2">
{link.description}
</p>
)}
{link.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{link.tags.map((t) => (
<span
key={t}
className="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded"
>
{t}
</span>
))}
</div>
)}
</div>
{/* Delete button calls the server function and refreshes data */}
<button
type="button"
onClick={async () => {
await handleDelete({ data: { id: link.id } })
router.invalidate()
}}
className="text-red-500 hover:text-red-700 text-sm ml-4"
>
Delete
</button>
</div>
</div>
What does this code do?
- useServerFn(deleteLink) wraps the deleteLink server function so it can be called safely from a React component.
- The onClick handler calls handleDelete with the link's ID. The server function removes it from the JSON file.
- router.invalidate() re-runs all active loaders after the deletion. This makes the board refresh and show the updated list without a full page reload.
- Save src/routes/index.tsx.
- Refresh your browser. The page still shows "No links yet" since there is no data. The delete button will be visible once you add links in the next step.
Seeing a compilation error?
- Make sure useRouter is imported from @tanstack/react-router and useServerFn is imported from @tanstack/react-start. They come from different packages.
- Check that deleteLink is imported alongside getLinks from ../utils/links.functions.
- help me fix my delete button code in index.tsx
Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
I'd like to double check the full code
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { getLinks, deleteLink } from '../utils/links.functions'
const searchSchema = z.object({
tag: z.string().optional().catch(undefined),
})
export const Route = createFileRoute('/')( {
validateSearch: searchSchema,
loaderDeps: ({ search }) => ({ tag: search.tag }),
loader: async ({ deps }) => {
const links = await getLinks()
if (deps.tag) {
return links.filter((link) => link.tags.includes(deps.tag!))
}
return links
},
component: BoardPage,
})
function BoardPage() {
const links = Route.useLoaderData()
const { tag } = Route.useSearch()
const router = useRouter()
const handleDelete = useServerFn(deleteLink)
const allTags = [...new Set(links.flatMap((link) => link.tags))]
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{tag ? `Links tagged "${tag}"` : 'All Links'}
</h1>
<Link
to="/add"
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700"
>
+ Add Link
</Link>
</div>
{allTags.length > 0 && (
<div className="flex gap-2 mb-6 flex-wrap">
<Link
to="/"
search={{}}
className={`px-3 py-1 rounded-full text-sm ${
!tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
All
</Link>
{allTags.map((t) => (
<Link
key={t}
to="/"
search={{ tag: t }}
className={`px-3 py-1 rounded-full text-sm ${
tag === t
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{t}
</Link>
))}
</div>
)}
{links.length === 0 ? (
<p className="text-gray-500 text-center py-12">
No links yet. Add your first one!
</p>
) : (
<div className="grid gap-4">
{links.map((link) => (
<div
key={link.id}
className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"
>
<div className="flex items-start justify-between">
<div>
<Link
to="/links/$linkId"
params={{ linkId: link.id }}
className="text-lg font-medium text-blue-600 hover:underline"
>
{link.title}
</Link>
<p className="text-sm text-gray-500 mt-1">{link.url}</p>
{link.description && (
<p className="text-sm text-gray-700 mt-2">
{link.description}
</p>
)}
{link.tags.length > 0 && (
<div className="flex gap-1 mt-2">
{link.tags.map((t) => (
<span
key={t}
className="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded"
>
{t}
</span>
))}
</div>
)}
</div>
<button
type="button"
onClick={async () => {
await handleDelete({ data: { id: link.id } })
router.invalidate()
}}
className="text-red-500 hover:text-red-700 text-sm ml-4"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
Your main board and navigation are ready. Next up, you will build the Add Link form so you can actually create links and see them appear on the board.
Build the Add Link Form
Your main board is live and can display, filter, and delete links. The next piece is giving users a way to actually add links to the board.
In this step, you will create a dedicated /add page with a form that collects a title, URL, description, and tags. The form submits through the createLink server function you built earlier, with proper error handling and loading feedback.
In this step, get ready to:
- Create a new route file for the Add Link page.
- Wire the form to submit data through a server function.
- Add error handling and a loading state for the submission flow.
Create the add link route
Each page in a TanStack Start app maps to a file inside the src/routes/ folder. Creating a file called add.tsx automatically registers a route at /add.
- In Cursor's Explorer sidebar, right-click the src/routes folder.
- Select New File and name it add.tsx.
- Add the following imports and route definition at the top of add.tsx:
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { createLink } from '../utils/links.functions'
// Register this file as the /add route
export const Route = createFileRoute('/add')({
component: AddLinkPage,
})
What does this code do?
- createFileRoute('/add') tells TanStack Router that this file handles the /add URL path.
- useNavigate is a hook from TanStack Router that lets you programmatically redirect the user after form submission.
- useServerFn wraps your createLink server function so it can be called safely from a React component.
You won't see a change in the browser yet. The next chunk adds the component function that makes this route render a form.
Build the form component with submission logic
The form needs controlled inputs for each field and a submit handler that sends the data to your server function. React's useState hook tracks each field's value so you can read them on submission.
- In src/routes/add.tsx, add the AddLinkPage component below the route definition you just wrote:
function AddLinkPage() {
const navigate = useNavigate()
// Wrap the server function for use in a component
const handleCreate = useServerFn(createLink)
// Controlled state for each form field
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [tagsInput, setTagsInput] = useState('')
// Track errors and loading state
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
// Split comma-separated tags into an array
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean)
try {
await handleCreate({
data: { title, url, description, tags },
})
// Redirect to the home page on success
navigate({ to: '/' })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create link')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Add a Link</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="title"
className="block text-sm font-medium text-gray-700 mb-1"
>
Title
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My favorite article"
/>
</div>
<div>
<label
htmlFor="url"
className="block text-sm font-medium text-gray-700 mb-1"
>
URL
</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com/article"
/>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description (optional)
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="A brief note about this link"
/>
</div>
<div>
<label
htmlFor="tags"
className="block text-sm font-medium text-gray-700 mb-1"
>
Tags (comma-separated)
</label>
<input
id="tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="react, tutorial, typescript"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Saving...' : 'Save Link'}
</button>
</form>
</div>
)
}
What does this code do?
- useServerFn(createLink) wraps the createLink server function so it can be called from inside a React component. In loaders, you can call server functions directly. Inside components, you need this hook.
- Six useState hooks track the four form fields plus an error message and an isSubmitting flag for loading state.
- handleSubmit prevents the default form reload, splits the comma-separated tags string into an array, and sends everything to the server function.
- On success, navigate({ to: '/' }) redirects the user back to the main board. On failure, the catch block stores the error message so it displays at the top of the form.
- Save src/routes/add.tsx.
- Open your browser to http://localhost:3000/add. You should see a form with fields for Title, URL, Description, and Tags, plus a blue Save Link button at the bottom.
Don't see the form?
- Make sure your dev server is still running. If it stopped, run npm run dev again from the link-board folder.
- Check that the file is named exactly add.tsx and lives directly inside src/routes/. A misplaced file won't register as a route.
- If you see a blank page or a TypeScript error in the terminal, verify that the AddLinkPage function name in the component matches the name in the route definition.
- My /add page isn't loading. Help me debug the route file.
Awesome, I've got everything!
Double check you've saved src/routes/add.tsx before moving on.
I'd like to double check the full code
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { createLink } from '../utils/links.functions'
export const Route = createFileRoute('/add')({
component: AddLinkPage,
})
function AddLinkPage() {
const navigate = useNavigate()
const handleCreate = useServerFn(createLink)
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [tagsInput, setTagsInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
const tags = tagsInput
.split(',')
.map((t) => t.trim())
.filter(Boolean)
try {
await handleCreate({
data: { title, url, description, tags },
})
navigate({ to: '/' })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create link')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="max-w-lg mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Add a Link</h1>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="title"
className="block text-sm font-medium text-gray-700 mb-1"
>
Title
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My favorite article"
/>
</div>
<div>
<label
htmlFor="url"
className="block text-sm font-medium text-gray-700 mb-1"
>
URL
</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://example.com/article"
/>
</div>
<div>
<label
htmlFor="description"
className="block text-sm font-medium text-gray-700 mb-1"
>
Description (optional)
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="A brief note about this link"
/>
</div>
<div>
<label
htmlFor="tags"
className="block text-sm font-medium text-gray-700 mb-1"
>
Tags (comma-separated)
</label>
<input
id="tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="react, tutorial, typescript"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Saving...' : 'Save Link'}
</button>
</form>
</div>
)
}
Test validation feedback and error handling
The form you just built has three layers of protection against bad data. Let's walk through each one and test them.
How does validation work in this form?
- HTML validation catches the basics. The required attribute on the title and URL inputs prevents empty submissions. The type="url" attribute ensures the browser checks for a valid URL format before the form even submits.
- Zod validation runs on the server. When the data reaches your createLink server function, the linkSchema you defined in schemas.ts checks the data again. If validation fails, Zod throws an error that the catch block picks up.
- Error display is the red banner above the form. The {error && (...)} block only renders when the error state is not null, giving the user a clear message about what went wrong.
What about the loading state?
The isSubmitting flag flips to true at the start of handleSubmit and back to false in the finally block. While it's true, the submit button text changes to "Saving..." and the button becomes disabled. This prevents double submissions.
Now test the full submission flow to make sure everything works end to end.
- Go to http://localhost:3000/add in your browser.
- Try clicking Save Link with all fields empty. The browser should show a validation tooltip on the Title field because it has the required attribute.
- Enter TanStack Docs in the Title field.
- Enter https://tanstack.com in the URL field.
- Enter react, typescript in the Tags field.
- Click Save Link. The button should briefly show "Saving..." and then redirect you to the home page at /.
You should see your new "TanStack Docs" link card on the main board with the tags "react" and "typescript" displayed below it.
Form submits but nothing appears on the board?
- Check your terminal for errors. If the Zod schema rejects the input, you'll see a validation error logged there.
- Confirm that links.json was created in the root of your link-board folder. If it doesn't exist, the writeLinks function in links.server.ts may have an issue with the file path.
- Make sure the import path ../utils/links.functions in add.tsx matches your file name exactly.
- My form submission isn't creating a new link. Help me figure out what's wrong.
Your add form is wired up and saving links to the board. Next up, you'll build a detail page for each link using a dynamic route parameter.
Build the Link Detail Page with Dynamic Params
Your Add Link form is wired up and saving links to the JSON file. Now you need a dedicated page to view each link's full details.
TanStack Router supports dynamic route parameters through its file naming convention. A file named $linkId.tsx automatically creates a URL parameter that captures whatever value appears in that position. You'll use this to build a detail page that loads a specific link by its ID.
In this step, get ready to:
- Create a server function to fetch a single link by its ID.
- Build a dynamic detail page using $linkId route params.
- Test the complete app flow end to end.
Create the getLinkById server function
The detail page needs a way to fetch a single link by its ID from the JSON file. If the ID doesn't match any stored link, the function should signal a "not found" state.
- In links.functions.ts, add this import on its own line below the existing createServerFn import:
import { notFound } from '@tanstack/react-router'
- Then add the getLinkById server function below the existing getLinks function by copying this code:
export const getLinkById = createServerFn({ method: 'GET' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
// Find the link matching the given ID
const links = await readLinks()
const link = links.find((l) => l.id === data.id)
if (!link) {
throw notFound()
}
return link
})
What does this code do?
- createServerFn({ method: 'GET' }) defines this as a read-only server function, just like getLinks.
- .inputValidator((data: { id: string }) => data) ensures the caller passes an object with an id string.
- throw notFound() triggers TanStack Router's not-found handling if no link matches the given ID.
- Save links.functions.ts.
You won't see a change in the browser yet. The detail page route you build next will call this function.
Seeing a red underline on notFound?
- Make sure you added import { notFound } from '@tanstack/react-router' at the top of the file.
- Check that notFound is imported from @tanstack/react-router, not from @tanstack/react-start.
- Why is my notFound import not working in links.functions.ts?
Awesome, I've got everything!
Double check you've saved links.functions.ts before moving on.
I'd like to double check the full code
import { createServerFn } from '@tanstack/react-start'
import { notFound } from '@tanstack/react-router'
import { linkSchema } from './schemas'
import { readLinks, writeLinks } from './links.server'
export const getLinks = createServerFn({ method: 'GET' }).handler(async () => {
return readLinks()
})
export const getLinkById = createServerFn({ method: 'GET' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const links = await readLinks()
const link = links.find((l) => l.id === data.id)
if (!link) {
throw notFound()
}
return link
})
export const createLink = createServerFn({ method: 'POST' })
.inputValidator(linkSchema)
.handler(async ({ data }) => {
const links = await readLinks()
const newLink = {
...data,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
}
links.push(newLink)
await writeLinks(links)
return newLink
})
export const deleteLink = createServerFn({ method: 'POST' })
.inputValidator((data: { id: string }) => data)
.handler(async ({ data }) => {
const links = await readLinks()
const filtered = links.filter((l) => l.id !== data.id)
await writeLinks(filtered)
return { success: true }
})
Create the dynamic route file
TanStack Router uses a $ prefix in filenames to define dynamic route segments. You need to create a links folder inside src/routes/ to namespace this route.
- In the Explorer sidebar in Cursor, right-click the src/routes folder and select New Folder.
- Name the folder links.
- Right-click the new links folder and select New File.
- Name the file $linkId.tsx.
What is a dynamic route parameter?
The $ prefix in $linkId.tsx tells TanStack Router to treat that URL segment as a variable. When a user visits /links/abc-123, the router captures abc-123 as the linkId param. You access it in the loader via params.linkId.
- In $linkId.tsx, add the route definition with a loader and not-found fallback by copying this code:
import { createFileRoute, Link } from '@tanstack/react-router'
import { getLinkById } from '../../utils/links.functions'
export const Route = createFileRoute('/links/$linkId')({
// Load the link data before the component renders
loader: async ({ params }) => {
return getLinkById({ data: { id: params.linkId } })
},
// Show a fallback UI if the link ID doesn't exist
notFoundComponent: () => (
<div className="text-center py-12">
<h2 className="text-xl font-bold text-gray-900 mb-2">Link not found</h2>
<Link to="/" className="text-blue-600 hover:underline">
Back to Board
</Link>
</div>
),
component: LinkDetailPage,
})
What does this code do?
- createFileRoute('/links/$linkId') registers a route that matches /links/ followed by any value.
- The loader receives params.linkId from the URL and calls getLinkById to fetch the matching link before the component renders.
- notFoundComponent renders a fallback UI when getLinkById throws notFound().
- component: LinkDetailPage references the component function you'll write next.
- Save $linkId.tsx.
You won't see a change in the browser yet. The LinkDetailPage component comes in the next substep.
File not recognized as a route?
- Make sure the file is at src/routes/links/$linkId.tsx with the $ prefix on the filename. Without it, the router won't treat the segment as a dynamic parameter.
- Check that the folder structure is routes/links/ with a links subfolder inside routes.
- My $linkId route isn't being recognized by TanStack Router.
Build the detail page component
- In $linkId.tsx, add the LinkDetailPage function below the Route definition by copying this code:
function LinkDetailPage() {
// Access the link data loaded by the route's loader
const link = Route.useLoaderData()
return (
<div className="max-w-lg mx-auto">
<Link
to="/"
className="text-sm text-blue-600 hover:underline mb-4 inline-block"
>
← Back to Board
</Link>
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900 mb-2">{link.title}</h1>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm break-all"
>
{link.url}
</a>
{link.description && (
<p className="text-gray-700 mt-4">{link.description}</p>
)}
{link.tags.length > 0 && (
<div className="flex gap-2 mt-4 flex-wrap">
{link.tags.map((tag) => (
<Link
key={tag}
to="/"
search={{ tag }}
className="bg-gray-100 text-gray-600 text-sm px-3 py-1 rounded-full hover:bg-gray-200"
>
{tag}
</Link>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-6">
Added {new Date(link.createdAt).toLocaleDateString()}
</p>
</div>
</div>
)
}
What does this code do?
- Route.useLoaderData() retrieves the link object that the loader fetched. The data is fully typed thanks to TanStack Router's type inference.
- The <a> tag opens the saved URL in a new browser tab with target="_blank".
- Each tag renders as a clickable <Link> that navigates back to the home page with that tag as a search param filter.
- new Date(link.createdAt).toLocaleDateString() formats the ISO timestamp into a human-readable date.
- Save $linkId.tsx.
- Make sure your dev server is running with npm run dev.
- In your browser, go to the main board at http://localhost:3000 and click on any link title.
You should see the link's title, URL, description, tags, and creation date displayed in a card.
Seeing a blank page or error?
- Make sure you have at least one link saved. If the board is empty, add a link from /add first.
- Check that the file path is exactly src/routes/links/$linkId.tsx. The links folder must exist inside routes.
- Verify that the LinkDetailPage function is defined in the same file as the Route export, below it.
- My link detail page at /links/$linkId is showing an error instead of the link details.
Awesome, I've got everything!
Double check you've saved $linkId.tsx before moving on.
I'd like to double check the full code
import { createFileRoute, Link } from '@tanstack/react-router'
import { getLinkById } from '../../utils/links.functions'
export const Route = createFileRoute('/links/$linkId')({
loader: async ({ params }) => {
return getLinkById({ data: { id: params.linkId } })
},
notFoundComponent: () => (
<div className="text-center py-12">
<h2 className="text-xl font-bold text-gray-900 mb-2">Link not found</h2>
<Link to="/" className="text-blue-600 hover:underline">
Back to Board
</Link>
</div>
),
component: LinkDetailPage,
})
function LinkDetailPage() {
const link = Route.useLoaderData()
return (
<div className="max-w-lg mx-auto">
<Link
to="/"
className="text-sm text-blue-600 hover:underline mb-4 inline-block"
>
← Back to Board
</Link>
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900 mb-2">{link.title}</h1>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm break-all"
>
{link.url}
</a>
{link.description && (
<p className="text-gray-700 mt-4">{link.description}</p>
)}
{link.tags.length > 0 && (
<div className="flex gap-2 mt-4 flex-wrap">
{link.tags.map((tag) => (
<Link
key={tag}
to="/"
search={{ tag }}
className="bg-gray-100 text-gray-600 text-sm px-3 py-1 rounded-full hover:bg-gray-200"
>
{tag}
</Link>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-6">
Added {new Date(link.createdAt).toLocaleDateString()}
</p>
</div>
</div>
)
}
Test the complete link board flow
Every feature of the Link Board is now in place. Time to test the full loop from creating a link to deleting it.
- Make sure your dev server is running with npm run dev.
- Open http://localhost:3000 in your browser.
- Click Add Link in the navigation bar.
- Fill in the form with a title, URL, description, and some comma-separated tags.
- Click Save Link.
- Confirm the app redirects you back to the board and your new link appears in the list.
- Click the title of the link you just added. Confirm the detail page loads with all the information you entered.
- On the detail page, click one of the tag pills. Confirm the board filters to show only links with that tag.
- Click All in the tag filter bar to see all links again.
- Click Delete on a link card. Confirm the link disappears from the board.
- Check the URL bar as you filter by tag. You should see ?tag=your-tag appended to the URL.
What should I see?
- The URL updates to something like /?tag=react when you click a tag filter.
- Refreshing the page with a tag filter in the URL preserves the filter.
- Navigating to a non-existent link ID (like /links/fake-id) shows the "Link not found" fallback.
Something not working as expected?
- If the tag filter doesn't update the URL, make sure your index.tsx has the validateSearch option with the search schema.
- If deleting a link doesn't remove it from the board, check that you're calling router.invalidate() after the delete completes.
- If the detail page shows "Link not found" for a link that exists, verify the getLinkById function is comparing data.id against l.id in the .find() call.
- My Link Board app isn't working correctly during the end-to-end test.
Secret mission
Add Link Editing
Your Link Board can create, read, filter, and delete links. But what if you want to fix a typo or update a description? Build an edit page with a pre-filled form and an updateLink server function to complete the full CRUD experience.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The link-board project directory with all source files.
- The links.json file storing your saved links.
- The node_modules folder with installed npm packages.
- The local Vite dev server process (if still running).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend your Link Board with the Secret Mission.
- Your project files stay in the link-board directory.
- Your saved links persist in links.json.
- You can restart the dev server anytime with npm run dev.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal and system memory. Your project files and saved links will stay on disk.
- Stop the dev server by pressing Ctrl + C in your terminal.
- To resume later, open the link-board folder in Cursor and run npm run dev.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Stop the dev server by pressing Ctrl + C in your terminal.
- Navigate to the parent directory of your project.
- Delete the entire link-board folder. This removes all source files, node_modules, and the links.json data file.
Mission accomplished
That's a Wrap!
Nice work! You just built a full-stack Link Board app with TanStack Start. You went from scaffolding a project with the CLI all the way to type-safe routing, server functions, and validated data flow.
You've learned how to:
- Build type-safe server functions with createServerFn for CRUD operations, validated with Zod schemas to guarantee data integrity.
- Implement file-based routing with createFileRoute across multiple pages, including a dynamic route with $linkId params and notFound() handling.
- Use type-safe search params with validateSearch and Zod to build tag-based filtering with loaders that re-fetch data automatically when the URL changes.
- Add a full edit flow with a pre-filled form and an updateLink server function to modify existing links.
Ready to quiz yourself?