The motivation for creating this template was to get ramped up with the new Vercel AI SDK. The new SDK is highly productive, promoting consistency, reducing lines of code, and improving overall readability when used properly. Furthermore, I also took this as an opportunity to streamline and clean up existing workflows. Focusing on structuring to improve productivity, readability and maintainability of all codebases across the board. If you’re interested in the visual and technical design made for the project, keep reading. Otherwise, if you just want to just use or read the code, you can find it on GitHub here: github.com/thepangolier/expression
Although visual design is not the template’s primary emphasis, it makes sense to explain this first. It ensures every reader benefits. I also have several nuanced insights that might be valuable to you.
The start page follows the familiar layout found in most prompt interfaces, it is comfortable and intuitive, but admittedly somewhat generic. By contrast, the toggleable bottom bar draws clear inspiration from the original Claude UI. Although the current Claude design has moved in the direction of... Everything else... This template deliberately retains the earlier style... a sleek, distinctive accent that I think would enhance many modern interfaces.
Conceptually, I think Grok’s Chat UI does the settings and history page in the most ergonomic way possible. Other Chat UIs force you to click a button to view the thread. Grok has a dialog and you hover each thread to view the history. Furthermore, it has a much better search bar that does fulltext search on threads instead of mapping to thread titles. The profile dialogue tab in Expression leaves a lot to be desired, but, the requirements for profiles in apps are so ambiguous that it was better to leave more plain. In Grok’s case, aesthetically, I am not the biggest fan of their profile tab either. But again, it is definitely the most ergonomic since it doesn’t force you to navigate to another page.
In most contexts, the method Grok implemented these features are the best purely from a User Experience perspective. However, it is very hard to get these dialogues to look aesthetic and visually appealing. I think UX and Ergonomics should take priority though.
The toggleable bar for prompting in the template simply generates snippets. It is meant to be there for other more potentially interesting things and tools. Furthermore, the prompt bar itself uses @blocknote/react which is a very nice notion style editor.
Adding to that, messages have a decent starting point for rendering markdown in a nicely formatted way. I heavily recommend enforcing Github Flavored Markdown outputs for LLMs, simply due to the fact that any decent 35b+ or MoE model will consistently do a good job with GFM markdown 99.99% of the time.
The Chat UI also demonstrates tool invocations. In this case, there are two tool calls. The first is a carousel, mostly to remind a person using the template that tool invocations can really be... anything. The second is a code invocation tool. The code invocation is very simple and just uses the default GFM rendering, I heavily recommend using something like shiki to better format code if you are trying to prototype a more code oriented tool. Furthermore, tool invocation style management for prompting can potentially help you manage the context window for generating code too if going down that route.
This summarizes everything related to the design. For full clarity, the template is not a full end to end web application template, it is meant to be a drop-in solution for projects that want to introduce a prompting interface.
The following outlines my reasoning for code structure and design. My primary goal is to enhance code readability and maintainability, and this template provided an excellent opportunity to refine both legacy and future codebases.
One major thing I’ve been moving in favour of is clear, verbose path aliases. I notice a lot of people using the @/... syntax, which looks ugly and also reduces readability. I’ve found that clear verbose path aliases that properly and canonically describe folders not only look better, but, improve readability of the codebase. For this project there are five path aliases as follows:
"paths": {
"@app/*": [
"app/*"
],
"@component/*": [
"component/*"
],
"@assets/*": [
"public/assets/*"
],
"@scss/*": [
"scss/*"
],
"@ai/*": [
"ai/*"
]
}
I started on this prompt template to get ramped up with the Vercel AI SDK and quite frankly. The ergonomics of it speak for itself. Setting up and changing providers are few lines, you can also opt for models tobe local or remote to your own servers via ollama.
import { createGoogleGenerativeAI } from '@ai-sdk/google'
export const google = createGoogleGenerativeAI({
apiKey: process.env.GOOGLE_API_KEY
})
export default function getProvider(model = 'gemini-2.0-flash') {
return google(model, {
useSearchGrounding: false,
safetySettings: []
})
}
Inference endpoints are also streamlined and made simple with the AI SDK. The Vercel AI SDK code speaks for itself in terms of how ergonomic it is for the Developer Experience.
import { type CoreMessage, smoothStream, streamText } from 'ai'
import { getProfile } from '@ai/action/profile'
import getProvider from '@ai/provider'
import system from '@ai/system'
import carousel from '@ai/tool/carousel'
import code from '@ai/tool/code'
export const maxDuration = 60
export interface ChatBody {
messages: CoreMessage[]
}
export async function POST(req: Request) {
const { messages } = (await req.json()) as ChatBody
const profile = await getProfile()
function systemInstructions() {
if (profile.instructionType === 'custom' && profile.instructions) {
return profile.instructions
} else {
return system
}
}
const result = streamText({
system: systemInstructions(),
model: getProvider(),
tools: {
carousel,
code
},
messages,
experimental_transform: smoothStream(),
onError({ error }) {
console.error('Chat Stream Error', error)
}
})
return result.toDataStreamResponse()
}
Another big advantage to the Vercel AI SDK is the improvements to DX for creating tools. I am still not the biggest fan of Zod and would prefer Ambient type declarations for tools and structured outputs. However, Zod’s declarative syntax is definitely more ergonomic when it comes to providing inline contextualization of tools and structured outputs.
import { tool } from 'ai'
import { z } from 'zod'
export default tool({
description:
'The carousel tool, use this when the user wants to create a carousel of images',
parameters: z.object({
items: z
.number()
.default(5)
.describe('The number of images to generate for the carousel')
}),
async execute({ items }) {
return items
}
})
As you can see in the snippet. There is inline contextualization of the tool with both a description as well as Zod’s describe. This would be a bit more verbose with Ambient types so it makes sense why Vercel used Zod.
This template is intentionally agnostic. It caches threads and profile data in .next and uses simple queries via node:fs. A key thing to note for the server actions are that I am moving towards a clear declarative format for the server action files. Each server action file should clearly describe the input and output. This effectively makes sure each file has each function associated with a Request and Response signature above it. You can see a sample of it as follows:
export interface GetProps { ... }
export interface GetResponse { ... }
/*
** Function annotation
*/
export async function Get({ ... }: GetProps): Promise<GetResponse> { ... }
export interface SaveProps { ... }
... and so forth...
Adding to that, ideally I would be organizing actual business logic for each function into modularized snippets. So that the function itself is moreso just documentation and annotation. This maximizes readability as well as understanding of the codebase with Intellisense via the function annotations. You only need to look at particular pieces of business logic when you need to, instead of having long verbose queries in actions.
In the case of this template. The actual business logic is inside the action. The SLOC is low and the general principle of modularization does not apply here since the likelihood of a snippet being reusable is incredibly low. For context, the following is the server action for getting and saving threads:
'use server'
import type { Message } from 'ai'
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
const historyPath = join(process.cwd(), '.next', 'history')
export interface SaveThreadProps {
threadId: string
message: Message
}
/**
* Appends a message to a thread’s history file, creating the history directory if needed.
*
* @param props - Properties for saving the thread message.
* @param props.threadId - Unique identifier for the thread; used as the filename (`{threadId}.json`).
* @param props.message - The Message object to append to the thread’s history.
* @returns Promise that resolves once the message has been written to disk.
*/
export async function saveThread({ threadId, message }: SaveThreadProps) {
if (!existsSync(historyPath)) {
mkdirSync(historyPath)
}
const threadPath = join(process.cwd(), '.next', 'history', `${threadId}.json`)
let messages: Message[] = []
if (existsSync(threadPath)) {
messages = JSON.parse(readFileSync(threadPath, 'utf-8')) as Message[]
}
messages.push(message)
writeFileSync(threadPath, JSON.stringify(messages, null, 2))
}
export interface Thread {
path: string
messages: Message[]
}
/**
* Loads all saved threads from disk and returns them sorted by most recent message.
*
* @returns Promise resolving to an array of Thread objects, each containing:
* - path: the filename of the thread (e.g. "thread-123.json")
* - messages: the array of Message objects in that thread
*/
export async function getThreads(): Promise<Thread[]> {
if (!existsSync(historyPath)) {
return []
}
const files = readdirSync(historyPath).filter(
(file) => file.startsWith('thread-') && file.endsWith('.json')
)
const threads: Thread[] = files.map((file) => {
const fullPath = join(historyPath, file)
const messages = JSON.parse(readFileSync(fullPath, 'utf-8')) as Message[]
return { path: file, messages }
})
threads.sort((a, b) => {
const aCreatedAt = new Date(
a.messages[a.messages.length - 1].createdAt || 0
).getTime()
const bCreatedAt = new Date(
b.messages[b.messages.length - 1].createdAt || 0
).getTime()
return bCreatedAt - aCreatedAt
})
return threads
}
There are probably three key takeaways on the React components in Expression. The first is the structure and enforcing the structure to maximize understanding of the codebase. Here is a high level overview of the structure:
component
/dialog
history.tsx
/profile
customize.tsx
info.tsx
profile.tsx
header.tsx
/prompt
bar.tsx
message.tsx
recipes.tsx
tool.tsx
/shared
dialog.tsx
editor.tsx
icon.tsx
title.tsx
I think the major takeaway here is making sure you keep the discipline to organize component and component logic in this canonically coherent way. For example, in dialog/profile, if the main profile component needs separation. Adding a subfolder for specific components for the profile in the same folder helps maintain coherence and consistency.
The second major thing is leveraging the first class support for RSC Next.js has. To demonstrate how much simpler server side api calls are. Look at the logic for loading threads as follows:
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [threads, setThreads] = useState<Thread[]>([])
const [preview, setPreview] = useState<Thread | null>(null)
useEffect(() => {
setLoading(true)
async function retrieveThreads() {
setThreads(await getThreads())
setLoading(false)
}
if (active) {
retrieveThreads()
}
}, [active])
useEffect(() => {
if (threads.length > 0) {
setPreview(threads[0])
}
}, [threads])
This is effectively becoming the idiomatic way to use Next.js and is incredibly productive. I don’t think much else needs to be said here the benefits of Next’s RSC flow speak for itself.
Finally, I want to demonstrate the React side of the Vercel AI SDK. The useChat hook simplifies thread management drastically. It provides all the key states and ways to set states through a spread on useChat. For context, the only abstraction you actually have to manually do yourself is the save thread part. See the PromptBar props for submitAction.
'use client'
/* imports... */
export default function RootPage() {
const [historyDialog, setHistoryDialog] = useState(false)
const [profileDialog, setProfileDialog] = useState(false)
const [threadId, setThreadId] = useState(`thread-${generateId()}`)
const {
messages,
setMessages,
status,
error,
input,
handleInputChange,
handleSubmit
} = useChat({
api: '/api/chat',
async onFinish(message) {
await saveThread({ threadId, message })
}
})
const resetThread = useCallback(() => {
setThreadId(`thread-${generateId()}`)
setMessages([])
}, [setMessages])
return (
<div id="expression" className="container">
...
<PromptBar
status={status}
error={error}
input={input}
setInputAction={handleInputChange}
submitAction={async (e) => {
await saveThread({
threadId,
message: {
id: `msg-${generateId()}`,
createdAt: new Date(),
role: 'user',
content: input
}
})
handleSubmit(e)
}}
/>
</div>
)
}
A final thing to note is that in this template, thread ids are managed client side. It is possible you might want to manage it on the server, where you can simply either store it via an encrypted cookie or send it via the body of the POST request. The Vercel AI SDK allows for both pretty easily.
I know a lot people have a dependency on tools like ShadCN and Tailwind. So the idea of using vanilla SCSS may be foreign and also add technical scope creep to vibe coders. Honestly, I would use ShadCN and Tailwind if I didn’t end up overwriting 80% of each property every time I used it.
I think the main thing to look at are two things. The theme file which is commented well. These are dense abbreviated variable names (I prefer it when writing SCSS) so the comments help provide clarity.
// Fonts
$font-header: "Figtree", sans-serif;
$font-copy: "Figtree", sans-serif;
// Core Colors
$pc: rgb(98 53 32); // Primary Color
$sc: rgb(209 179 129); // Secondary Color
$fc: rgb(18 7 1); // Foreground Color
$bc: rgb(245 234 227); // Background Color
$bc-user: rgb(224 202 164); // Background Color for User Prompts
$bc-model: rgb(225 195 144); // Background Color for Model Prompts
$g-shadow: rgb(113 63 36 / 10%); // Generic Box Shadow Color
// Status Colors
$gc: rgb(100 194 86); // Green Status Color
$rc: rgb(194 86 86); // Red Status Color
// Dimensions
$app-width: 768px;
$hug: 15px; // Generic Padding and Margin
$c-size: 42px; // Generic Component Height / Square Size
$c-radius: 9px; // Generic Component Border Radius
The second is a preview of the button scss file. It is also fairly dense, but, I really like this mixin style where I can simply import it as follows:
@use "@scss/generic/button" as button;
div.thing {
button {
@include button.generic;
& {
... custom properties
}
}
}
I used to use Tailwind a fair bit in the past. But it becomes just as dense and then even more dense with the level of property overwriting you have to do. And I tried my best to get into ShadCN but, quite frankly, I like designing applications with high touch and get better results and also spend less time on style in the long run.
@use "@scss/shared/theme" as *;
@mixin generic {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: calc($c-size * 0.8);
margin: 0;
padding: 0 calc($hug * 1);
border: 2px solid transparent;
border-radius: 8px;
color: $fc;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease-in-out;
background: rgba($pc, 0.1);
svg {
font-size: 18px;
position: relative;
top: 0;
margin: 0 calc($hug * 0.33) 0 0;
}
&:hover {
background: rgba($pc, 0.2);
color: $fc;
}
&:active,
&.active {
background: rgba($pc, 0.4);
color: $fc;
}
&:disabled,
&.disabled {
opacity: 0.5;
pointer-events: none;
}
@media (width <= 600px) {
height: calc($c-size * 0.8);
padding: 0 calc($hug * 1);
border-radius: calc($c-size * 0.4);
font-size: 10px;
}
}
... and so forth
This wraps up everything for Expression. If you want to take a closer look at the code just go to github.com/thepangolier/expression. And if you want to work me, you can contact me on @thepangolier or email me at hello@pangolier.com.