Dev Tips

Read the latest tips and tricks for building your SaaS. Get fresh developer tips every week.

Filter tips:

2025-09-04

Use Zustand for minimal, high-performance global state management in React

#react #next.js #typescript

React's Context API and useState can work, but they often lead to prop drilling, unnecessary re-renders, and verbose boilerplate.

Enter Zustand: a tiny (~3 kB), unopinionated, hook-based state library that’s fast, scalable, and delightfully simple.

import { create } from 'zustand';
 
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));
 
function Counter() {
  const { count, increment } = useStore((state) => ({
    count: state.count,
    increment: state.increment,
  }));
  return <button onClick={increment}>Clicked {count} times</button>;
}

Why Zustand shines:

  • No providers needed—store is a hook you use anywhere
  • Selective subscriptions—components only re-render when the selected slice changes
  • Minimal boilerplate—no action creators, reducers, or context factories

Use Zustand whenever you need global or shared state with less hassle, better performance, and fuss-free scaling.

2025-09-03

Use nuqs for type-safe URL-synced state in React

#react #next.js #typescript

Storing component state in the URL makes it bookmarkable and sharable.
But manually reading and writing searchParams can get ugly—and fragile.

Enter nuqs: a tiny (~5.5 kB gzipped) React hook that works like useState, but syncs state with the URL—fully type-safe and framework-agnostic.

import { parseAsInteger, useQueryState } from 'nuqs';
 
function Counter() {
  const [count, setCount] = useQueryState(
    'count',
    parseAsInteger.withDefault(0)
  );
  return (
    <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
  );
}

Why it's a win:

  • Type-safe parsing: converts query strings into numbers, booleans, dates, enums, and more.
  • Batched, throttled updates: nudges the URL without overloading History API (no crashes from too-many updates).
  • Cross-framework support: works with Next.js (both routers), Remix, React Router, TanStack Router, and plain React out-of-the-box.

It also provides helpers like useQueryStates for managing multiple params together, and a server-side cache (createSearchParamsCache) for SSR-friendly workflows.

Use nuqs when:

  • You want state synced to URL in a type-safe, shareable way.
  • You need multiple frameworks or SSR support.
  • You'd love less boilerplate, safer parsing, and smoother URL handling.

Learn more about nuqs.

2025-09-02

Use generateObject to get structured data from an LLM with the AI SDK

#ai #typescript

LLMs often return free-form text, which can be hard to parse.
With the AI SDK, you can use generateObject to directly enforce a typed JSON schema — no more brittle regex or string parsing.

import { openai } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import { z } from 'zod';
 
const result = await generateObject({
  model: openai('gpt-4.1'),
  schema: z.object({
    name: z.string(),
    age: z.number(),
  }), 
  prompt: "Extract the person's name and age: John is 42 years old.",
});
 
console.log(result.object);
// → { name: "John", age: 42 }

Why it’s great:

  • Enforces strict structure with Zod
  • No messy post-processing
  • Safer integration with TypeScript

Use generateObject whenever you want predictable, typed data from an LLM.

Learn more about generateObject in the AI SDK documentation.

2025-09-01

Use the size- utility in Tailwind for cleaner sizing

#tailwindcss #css #frontend

When you want a square element in Tailwind, you might write both width and height:

<div class="w-16 h-16 bg-blue-500"></div>

But Tailwind has a shorthand: size-.

It sets both width and height at the same time:

<div class="size-16 bg-blue-500"></div>

This keeps your classes shorter and easier to read. Perfect for avatars, icons, or any element that should always be square.

Learn all about the size- utility in the Tailwind CSS documentation.

2025-08-29

Simplify Permission Checks in TypeScript with Permix

#typescript #auth #frontend

Permix is a lightweight, zero-dependency TypeScript library that provides type-safe permission management. It helps you avoid common pitfalls like typos and inconsistent permission names.

Quick Start

import { createPermix } from 'permix'
 
const permix = createPermix<{
  post: { action: 'read' | 'write' }
}>()
 
permix.setup({
  post: { read: true, write: false }
})
 
const canRead = permix.check('post', 'read') // true
const canWrite = permix.check('post', 'write') // false

Define Permissions

const permix = createPermix<{
  post: {
    dataType: Post,
    dataRequired: true,
    action: 'create' | 'read' | 'update' | 'delete'
  }
}>()
 
const isPostAuthor = (post: Post) => post.authorId === userId
 
permix.setup(({ id: userId }: User) => ({
  post: { 
    create: false, 
    read: true, 
    update: isPostAuthor, 
    delete: isPostAuthor,  
  }
}))

Key Benefits

  • 100% type-safe without additional TypeScript code
  • Single source of truth for app permissions
  • Zero dependencies
  • Framework agnostic with React, Vue, Express integrations

Permix makes permission management simple and reliable. Learn more in the official documentation.

2025-08-28

Add automatic top loader bar to Next.js

#next.js #ux

Adding a loading progress indicatorthat shows automatically when navigating between pages can significantly improve your application's user experience. It provides visual feedback that a new page is loading and reduces perceived latency.

While you could build this yourself, the @bprogress/next package makes it incredibly simple to implement. It works seamlessly with the Next.js App Router and doesn't require any manual state management.

Installation

First, install the package in your Next.js project:

npm install @bprogress/next

Usage

  1. Wrap your root layout: Import the ProgressProvider and wrap your layout.tsx file with it. This component handles the state of the progress bar for you.
// app/layout.tsx
 
import { ProgressProvider } from '@bprogress/next';
import './globals.css';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ProgressProvider>
          {children}
        </ProgressProvider>
      </body>
    </html>
  );
}
  1. Add the progress bar component: Create a new client component to display the progress bar. You can place this component anywhere in your application, but a common place is the root layout so it's always visible.
// components/ProgressBar.tsx
 
'use client';
 
import { ProgressBar } from '@bprogress/next';
 
export default function AppProgressBar() {
  return <ProgressBar />;
}
  1. Import and render the component: Finally, import your new AppProgressBar component into your root layout.
// app/layout.tsx
 
import { ProgressProvider } from '@bprogress/next';
import AppProgressBar from '@/components/ProgressBar'; // Update this path
import './globals.css';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ProgressProvider>
          {children}
          <AppProgressBar />
        </ProgressProvider>
      </body>
    </html>
  );
}

That's it! Now, whenever you navigate between pages, the progress bar will automatically appear at the top of the screen.

Customization

The ProgressBar component can be customized with various props to match your application's design:

  • color: Change the color of the progress bar (e.g., '#FF0000' or 'rgb(255, 0, 0)').
  • height: Adjust the thickness of the bar (e.g., '2px').
  • background: Set a background color for the container.
  • showOnShallow: Control whether the bar shows on shallow navigations (e.g., between anchors on the same page).

For more details on customization, you can check out the @bprogress/next package.

2025-08-27

Eliminate loading states with server-side prefetching for TanStack Query

#next.js #tanstack #react #data fetching #ssr

Have you ever wanted to avoid a loading spinner on a page that fetches data when using Tanstack Query? Fetching data on the client-side often results in a "flash of content" as the data arrives.
In a Next.js application, you can prefetch your queries on the server and deliver a fully-rendered page with zero loading states, while still keeping all the client-side benefits of Tanstack Query.

This is a powerful technique where you fetch your data on the server and then "hydrate" the client-side cache so your components can render instantly.

Here's how to implement it:

// app/page.tsx
import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
 
// A simple function to fetch data
async function fetchTodos() {
  const response = await fetch('[https://jsonplaceholder.typicode.com/todos](https://jsonplaceholder.typicode.com/todos)');
  return response.json();
}
 
export default async function Page() {
  // 1. Create a new QueryClient instance on every request
  const queryClient = new QueryClient();
 
  // 2. Prefetch the query on the server
  // This fetches and caches the data before the component renders
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });
 
  // 3. Dehydrate the state and pass it to the client
  // The dehydrated state will be available to the client-side useQuery hook
  const dehydratedState = dehydrate(queryClient);
 
  return (
    <HydrationBoundary state={dehydratedState}>
      {/* Your client component will use useQuery here */}
      <TodosList />
    </HydrationBoundary>
  );
}
 
// components/TodosList.tsx (Client Component)
// This file is a separate Client Component with 'use client' at the top
'use client';
import { useQuery } from '@tanstack/react-query';
 
function TodosList() {
  // useQuery will find the data in the pre-fetched cache,
  // so it will not show a loading state
  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos, // You need to provide the queryFn again here
  });
 
  if (isLoading) {
    return <div>Loading...</div>;
  }
 
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

How it works:

  • QueryClientProvider and HydrationBoundary: You wrap your application in these providers to make the client-side cache available on the server and to serialize the data for the client.
  • queryClient.prefetchQuery(): This function fetches your data on the server-side, populating the cache.
  • dehydrate(): This serializes the populated cache into a plain JSON object.
  • HydrationBoundary: This component receives the serialized state and hydrates the client-side cache.
  • useQuery() on the client: When this hook runs, it finds the data already in the cache, so it renders immediately without a loading state.

This method is perfect for pages where you need to display dynamic data without a flicker, such as a product page or a user's dashboard.

For a more detailed explanation, you can check out the TanStack Query SSR documentation

2025-08-26

Manage your sites rich content in your codebase with content-collections and MDX

#cms #marketing #blog

Traditionally, rich content like blog posts, documentation, or marketing pages is managed in a separate CMS (Content Management System).
But what if you want to keep your content close to your code and manage it with tools you already use, like Git?

This is where content-collections and MDX come in.

  • MDX lets you write Markdown with embedded React components.
  • content-collections is a library that takes your markdown files, validates them against a schema, and turns them into type-safe data collections.

It's a powerful combination that provides a Git-based CMS. You can manage your content as simple files in your codebase, just like your code.

Here's a simple setup for content-collections:

// content-collections.ts
import { defineCollection, defineConfig } from "@content-collections/core";
import { z } from "zod";
 
const posts = defineCollection({
    name: "posts",
    directory: "content/posts",
    schema: (p) =>
        z.object({
            title: z.string(),
            date: z.string().transform((d) => new Date(d)),
            summary: z.string(),
        }),
});
 
export default defineConfig({
    collections: [posts],
});

Usually you integrate content-collections with your framework of choice (e.g. Next.js), so that you content generation is part of the build or development process.

Then you can access your content directly in your Next.js app with full type completion:

import { allPosts } from "content-collections";
 
export function BlogPage () {
    return (<div>
        {allPosts.map((post) => (
            <div key={post.path}>
                <h2>{post.title}</h2>
            </div>
        ))}
    </div>)
}

The process:

  • Define your content structure using a Zod schema.
  • Create your content in the content/posts directory.
  • Run content-collections generate to create type-safe data.
  • Access your content directly in your Next.js app with full type completion.

You can do all kinds of powerful things like in MDX like rich-text content or integrating React components for interactive elements.

This approach is perfect for blogs, documentation, or static sites where you want a fast, developer-friendly workflow without an external database or API.

For a more detailed explanation, you can check the official documentation.

2025-08-25

Use container queries for component-first responsiveness

#tailwindcss #responsive #css #ux #design

Responsive design often uses global breakpoints (sm:, md:, etc.).
But sometimes a component should adapt to its parent container, not the whole viewport.

That’s where container queries come in, which has become a widely supported a CSS feature. Tailwind makes them easy to use:

<div className="@container">
    <div className="@md:text-xl @lg:text-2xl">
        I resize based on my parent width, not the window size!
    </div>
</div>

How it works:

  • Add @container to the parent element
  • Use @ prefixes (@md:, @lg:) for container-based breakpoints

This makes components truly reusable — they respond correctly wherever they’re placed, not just at global screen sizes.

Great for dashboards, cards, or nested layouts where viewport breakpoints aren’t enough.

For a more detailed explanation, you can check out the Tailwind CSS Container Queries documentation.

2025-08-24

Use keepPreviousData for smooth paginated queries

#tanstack #react #ux

When building pagination with TanStack Query, you might notice the UI briefly "flashes empty" while loading the next page.
That’s because the old data is cleared as soon as the query key changes.

You can fix this with keepPreviousData.
It tells Tanstack Query to keep showing the old result until the new one arrives.

import { keepPreviousData, useQuery } from '@tanstack/react-query'
 
const { data, isLoading, isFetching } = useQuery({
  queryKey: ["projects", page],
  queryFn: () => fetchProjects(page),
  placeholderData: keepPreviousData,
});

Now, when page changes:

  • The old page data stays visible
  • A loading spinner can still indicate fetching
  • The transition feels much smoother

Perfect for paginated lists, infinite scroll, or any UI where flickering content hurts UX.

2025-08-21

Use autocomplete attribute on html inputs to improve form UX

#html #frontend #ux

The autocomplete attribute helps browsers prefill form fields with stored data.
This makes forms faster to complete and reduces user frustration.

<form>
  <input type="email" name="email" autocomplete="email" placeholder="Email" />
  <input type="password" name="password" autocomplete="current-password" placeholder="Password" />
  <input type="text" name="name" autocomplete="name" placeholder="Full name" />
  <input type="tel" name="phone" autocomplete="tel" placeholder="Phone number" />
  <button type="submit">Sign in</button>
</form>

Some useful values:

  • email → suggests stored emails
  • current-password / new-password → helps with password managers
  • name, tel, address-line1, postal-code → autofill personal details

By using autocomplete properly, you reduce typing effort and give users a smoother experience.

Visit the MDN docs for more information and a list of all possible values.

2025-08-20

Use input type for better mobile keyboards

#html #frontend #a11y #mobile #ux

On mobile devices, the type attribute of an <input> not only validates data but also brings up context-aware keyboards.
This makes forms easier and faster to use.

<!-- Brings up numeric keypad -->
<input type="number" placeholder="Age" />
 
<!-- Brings up phone dialer keypad -->
<input type="tel" placeholder="Phone number" />
 
<!-- Brings up email keyboard with @ and .com -->
<input type="email" placeholder="Email" />
 
<!-- Brings up URL keyboard with / and . keys -->
<input type="url" placeholder="Website" />
 
<!-- Brings up date picker -->
<input type="date" placeholder="Birthday" />
 
<!-- Brings up time picker -->
<input type="time" placeholder="Time" />
 
<!-- Brings up search keyboard -->
<input type="search" placeholder="Search" />
 
<!-- ... and more! -->

Using the right input type improves UX, reduces input errors, and makes your app feel more polished on mobile devices.

2025-08-19

Swap Lodash for es-toolkit for leaner, faster utils

#typescript #performance

Swap Lodash for es-toolkit for leaner, faster utils

Looking to slim down your bundle and speed up utility functions?
Meet es-toolkit — a modern, lightweight replacement for Lodash.

Simply swap imports like this:

// old Lodash import
import { pick } from 'lodash-es';
// new es-toolkit import
import { pick } from 'es-toolkit';

Why you'll love es-toolkit:

  • Tiny bundle size — up to 97% smaller than Lodash equivalents.
  • Blazing fast performance — often 2–3× faster, with some functions up to 11× faster.
  • Complete Lodash compatibility — via the es-toolkit/compat layer — seamless migration.

✨ Bonus: Utilities Lodash doesn’t have

es-toolkit also ships with handy functions that Lodash never included:

import { clamp, toggle, partitionObject } from 'es-toolkit';
 
// Clamp numbers between a min and max
clamp(150, 0, 100); 
// → 100
 
// Toggle a boolean value
toggle(true);
// → false
 
// Partition an object into [matching, rest]
partitionObject({ a: 1, b: 2, c: 3 }, (val) => val > 1);
// → [{ b: 2, c: 3 }, { a: 1 }]
 
// Attempt a promise and return data or error without throwing
const [error, data] = await attemptAsync(async () => {
  const response = await fetch('https://api.example.com/data');
  return response.json();
});
// → { data: { ... } } or { error: 'Error message' }
 
// ... and more!
2025-08-18

Truncate text with Tailwind's line-clamp utility

#tailwind #css #styling #design

Truncate text with Tailwind's line-clamp utility

Long text can easily break your layout or push important content out of view.
Instead of writing custom CSS for truncation, Tailwind gives you the line-clamp utility out of the box, which will truncate the text to a given number of lines and replace the rest with an ellipsis.

<p className="line-clamp-2">
  This is a very long paragraph that will be truncated
  after two lines. The rest of the content will not be
  displayed but replaced with an ellipsis.
</p>
  • line-clamp-1 → truncates after one line
  • line-clamp-2 → truncates after two lines
  • line-clamp-3 → truncates after three lines, and so on

This is perfect for previews (like blog post cards or product descriptions) where you only want to show a snippet.

2025-08-17

Use skipToken for type-safe query disabling in TanStack Query

#tanstack #react #typescript

When using useQuery, you may want to disable fetching until a condition is met. While enabled: false works, it doesn't narrow types inside your queryFn—TypeScript still sees the parameter as possibly undefined.

For a type-safe alternative, use skipToken:

import { useQuery, skipToken } from '@tanstack/react-query';
 
function Todos() {
  const [filter, setFilter] = React.useState<string | undefined>();
 
  const { data } = useQuery({
    queryKey: ['todos', filter],
    // Type-safe: skip query when `filter` is undefined
    queryFn: filter ? () => fetchTodos(filter) : skipToken,
  });
 
  return (
    <div>
      <FiltersForm onApply={setFilter} />
      {data && <TodosTable data={data} />}
    </div>
  );
}
2025-08-16

Use .nullish() instead of .optional().nullable() in zod

#zod #typescript

When working with zod, you might have found yourself using .optional().nullable() to make a field optional and nullable. Especially when you are working with tRPC or similar API libraries, this is a common case for API input validation.

But there is a better way to do this.

Instead of using .optional().nullable(), you can use .nullish().

const schema = z.object({ 
    name: z.string().optional().nullable(), // ❌ don't do this
    name: z.string().nullish(), // ✅ do this instead
});
2025-08-15

Create Tailwind CSS shades from your brand colors

#tailwind #css #styling #design

Create Tailwind CSS shades from your brand colors

When using Tailwind CSS to style your UI, you might have noticed that they use a shade system for colors. Usually when you pick brand colors though, you don't necessarily pick all shades for them but only a few specific colors.

To create shades that you can easily use in your Tailwind CSS classes and therefore in your UI, you can use the uicolors.app tool.

It allows you to enter the hex code of your brand color and will generate all the shades for you, as well as give you recommendations on what shades to use where in your UI including contrast ratios.

New: Your Idea. Our Experts. We'll Build Your SaaS For You.

Our 'Done For You' service simplifies the process, taking your concept and delivering a production-ready MVP or complete SaaS solution.

Start your scalable and production-ready SaaS today

Save endless hours of development time and focus on what's important for your customers with our SaaS starter kits for Next.js, Nuxt 3 and SvelteKit

Get started

Stay up to date

Sign up for our newsletter and we will keep you updated on everything going on with supastarter.