3876 stories
·
3 followers

Component Architecture for React Server Components

1 Share

For most of React’s history, the conventional way to load data on a page has been to fetch at the top of a route and pass it down through props. Most React developers still reach for that model first, even when working in the Next.js App Router.

In this blog post, we will look at why that habit ends up producing tightly coupled components and clumsy loading states, and explore how React Server Components let us architect a page differently. We will walk through the progression from useEffect to React Query to loaders to RSCs, and then put together a page that describes the loading experience rather than managing all the data.

Table of contents

Background

Data fetching on the server is faster than fetching on the client, for a straightforward reason. When we fetch on the client, we have to wait for the JavaScript bundle to download, parse, and execute before the first request can even fire. As the UI renders and more components mount, each one can trigger its own fetch, leading to waterfalls where requests happen in sequence rather than in parallel. The server, on the other hand, sits next to the database and can fetch in parallel with rendering, sending the result inline with the HTML. The end user gets data without paying for an extra roundtrip.

This is why loaders in frameworks like the old Remix (v1 and v2), the Next.js Pages Router, and more recently React Router v7 and TanStack Router have been so popular. They put data fetching at the route boundary on the server, which is the right place for it. With TanStack Router the loader is actually optional, and a common setup is to combine it with TanStack Query, where each component still uses its own useQuery for data and the loader only kicks off prefetching for that data on the route. That’s arguably a nicer split, because we keep component-local fetching while still getting the route-level head start.

The question is what we lose in the process, and whether RSCs let us keep the server-side wins without the trade-offs. For a deeper look at the performance side of this, Nadia Makarevich’s article React Server Components: Do They Really Improve Performance? is a great companion to this post. She measures the same app across CSR, SSR with loaders, and RSCs, and shows that the real performance gains only land once we rewrite data fetching to be server-first and add deliberate Suspense boundaries.

The Use Case

For the rest of this post, let’s imagine we are building a social feed page. The UI has a sidebar, a feed of posts, a list of suggested users to follow, and a list of trending tags. In plain JSX, the page looks something like this:

function HomePage() {
  return (
    <Layout>
      <Sidebar />
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Layout>
  );
}

This is just the layout. No data yet, no fetching, no loading states. Every component here will eventually need data, but right now we are just describing what the page looks like. From here, we can explore how different approaches to data fetching change the shape of this page.

1. Local Data Fetching

The original way to handle data in React was with useEffect and useState. Each component fetches its own data, owns its own loading flag, and lifts state up when something else needs to know:

function Feed() {
  const [posts, setPosts] = useState<PostT[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchFeed().then(p => {
      setPosts(p);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) return <FeedSkeleton />;
  return <ul>{posts.map(post => <Post key={post.id} post={post} />)}</ul>;
}

This works for a single component, but the moment another part of the tree needs the same data, we have to hoist posts and setPosts up to a common ancestor and pass them down. Mutations follow the same pattern: if Post wants to like itself and have the count update elsewhere, the like handler has to live somewhere both components can reach, which is usually higher than either of them needs to be. We end up lifting state for reasons that have nothing to do with the UI structure:

function HomePage() {
  const [posts, setPosts] = useState<PostT[]>([]);
  // ...fetch logic...

  function handleLike(postId: string) {
    likePost(postId).then(() => {
      fetchFeed().then(setPosts);
    });
  }

  return (
    <Feed posts={posts} onLike={handleLike} />
  );
}

function Feed({ posts, onLike }: Props) {
  return (
    <ul>
      {posts.map(post => (
        <Post key={post.id} post={post}>
          <LikeButton onClick={() => onLike(post.id)} />
        </Post>
      ))}
    </ul>
  );
}

The like handler lives in HomePage because it needs to update posts. Feed receives both the data and the callback. LikeButton has no idea where the handler comes from. Everything flows through props.

React Query and similar libraries cleaned this up significantly. The data is keyed and cached centrally, so any component can ask for it without prop drilling, and mutations can invalidate or update entries from anywhere:

function Feed() {
  const { data, isLoading } = useQuery({ queryKey: ["feed"], queryFn: fetchFeed });
  if (isLoading) return <FeedSkeleton />;
  return <ul>{data.map(post => <Post key={post.id} post={post} />)}</ul>;
}

function LikeButton({ postId }: { postId: string }) {
  const qc = useQueryClient();
  const mutation = useMutation({
    mutationFn: () => likePost(postId),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["feed"] }),
  });
  return <button onClick={() => mutation.mutate()}>Like</button>;
}

Suddenly the state doesn’t need to live higher than the component that owns it. LikeButton can sit deep in the tree, fire its mutation, and the Feed query refetches without anyone above either component knowing. This is genuinely better, and a big part of why React Query has the position it does.

The downside, in either case, is that every component decides when it is ready independently, and we end up with popcorn UI: things pop into the page one by one in whatever order the network happens to return them. We haven’t designed a loading sequence, we have outsourced it to the network. On top of that, all of this fetching happens on the client, so the user has to wait for the JavaScript to download and execute before any data request even starts. Loaders were an attempt to solve this.

2. Route-Level Loaders

To fix the client-fetching problem, we can move the data fetching to the server with a route-level loader. Instead of each component fetching on its own, a single function fetches everything the page needs up front, and the result is passed down to the page component. In React Router, that looks like this:

// React Router / Remix style
export async function loader() {
  const user = await getCurrentUser();
  const [feed, whoToFollow, trendingTags] = await Promise.all([
    getFeed(user.handle),
    getWhoToFollow(user.handle),
    getTrendingTags(),
  ]);
  return { user, feed, whoToFollow, trendingTags };
}

export default function HomePage() {
  const { user, feed, whoToFollow, trendingTags } = useLoaderData<typeof loader>();

  return (
    <Layout>
      <Sidebar user={user} />
      <Feed posts={feed.posts} currentUser={user} />
      <aside>
        <TrendingTags tags={trendingTags} />
        <WhoToFollow users={whoToFollow} currentUser={user} />
      </aside>
    </Layout>
  );
}

The equivalent in the old Next.js Pages Router would be getServerSideProps, which passes the data as props to the page. Either way, the loader sits at the route boundary and the components below it receive concrete data shapes. Notice that mutations like the LikeButton from earlier are no longer visible at the page level: the loader only handles reads, and writes typically go through separate API calls or form submissions that trigger a page reload or revalidation.

The same mindset is easy to recreate at the page component level in the Next.js App Router. We just make the page itself async and await everything at the top:

// Next.js App Router, loader mindset
export default async function HomePage() {
  const user = await getCurrentUser();
  const [feed, whoToFollow, trendingTags] = await Promise.all([
    getFeed(user.handle),
    getWhoToFollow(user.handle),
    getTrendingTags(),
  ]);

  return (
    <Layout>
      <Sidebar user={user} />
      <Feed posts={feed.posts} currentUser={user} />
      <aside>
        <TrendingTags tags={trendingTags} />
        <WhoToFollow users={whoToFollow} currentUser={user} />
      </aside>
    </Layout>
  );
}

The framework is different, but the shape is identical. The page is still the data owner, and the components are still views that receive whatever the page chose to fetch.

This feels organized, but the components are now coupled to whatever the page chose to fetch for them. Our WhoToFollow component just renders whatever it receives:

function WhoToFollow({ users, currentUser }: Props) {
  return (
    <ul>
      {users.map(user => (
        <UserRow key={user.handle} user={user} currentUser={currentUser} />
      ))}
    </ul>
  );
}

On the home page, it works fine because the page already fetches whoToFollow. But now we want to reuse it on a profile page too:

// home page
const [user, feed, whoToFollow] = await Promise.all([
  getCurrentUser(),
  getFeed(/* ... */),
  getWhoToFollow(/* ... */),
]);

// profile page (now needs the same thing)
const [user, profile, whoToFollow] = await Promise.all([
  getCurrentUser(),
  getProfile(handle),
  getWhoToFollow(/* ... */), // duplicated
]);

<WhoToFollow users={whoToFollow} currentUser={user} />;

The component itself didn’t change, but every new route that wants to use it has to fetch the same data, in the same shape, and thread the same props through every wrapper above it. The component is essentially welded to whichever loader happens to be fetching its data. This is inherent to the loader pattern in any framework: the data lives at the route boundary, and everything below it is a view that receives props.

3. Async Server Components

What if each component could fetch its own data on the server, without needing a loader to hand it down? That is exactly what React Server Components enable. They can be async, they run on the server, they can read from the database directly, and they never execute in the browser. This is what lets us keep the composability of the useEffect approach while still fetching on the server like with loaders: each component owns its data, but the fetch happens during server rendering and the result is sent to the client as rendered HTML.

The Next.js App Router is where most developers encounter RSCs today, and it makes this the default: every component is a server component unless we explicitly mark it with "use client".

Instead of the page fetching everything and passing it down, each component fetches what it needs based on minimal props, usually just an identifier. The component is self-contained: the consumer passes the minimum it needs to know (often just an ID or a handle), and the component resolves whatever else it requires internally. Let’s take WhoToFollow from the loader example. As a server component, it doesn’t need the users and currentUser props the page was handing it. It can resolve the current user and fetch the list itself:

export async function WhoToFollow() {
  const handle = await getCurrentUserHandle();
  const users = await getWhoToFollow(handle);
  return (
    <ul>
      {users.map(user => (
        <UserRow key={user.handle} handle={user.handle} />
      ))}
    </ul>
  );
}

Now we can use <WhoToFollow /> on any page without wiring up the data from above. The same component that needed two separate loaders earlier just works.

The same applies to Feed. In the loader version, it received posts and currentUser as props. As a server component, it fetches its own data and renders the list of posts directly:

export async function Feed() {
  const handle = await getCurrentUserHandle();
  const { posts } = await getFeed(handle);
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <Post post={post} />
        </li>
      ))}
    </ul>
  );
}

The page just renders <Feed />. Notice that Feed passes the whole post object down to Post. We already have the data from the feed query, so there is no reason for each Post to refetch its own row by id.

With every component fetching its own data, the page itself goes back to looking like this:

export default function HomePage() {
  return (
    <Layout>
      <Sidebar />
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Layout>
  );
}

The structure is the same as the use case. The difference is that every component in this tree is now fetching its own data on the server.

You might be worried about duplicate fetches at this point. With each component fetching its own data, the same getCurrentUserHandle could be called from multiple places in the same render. React’s cache() function deduplicates these per request, so calling it ten times in the same render hits the source once. This is similar to what React Query’s centralized cache does on the client, but built into the server render itself. I covered this in more depth in my previous post on Avoiding Server Component Waterfall Fetching with React 19 cache().

cache() handles deduplication, but not batching. If many components on a page each fetch their own data by a different identifier, you can end up making a lot of separate requests that could have been one. See @_mjmeyer’s answer for more on this.

This composability is also why AI coding agents work so well with React in general, and RSCs extend that composability model to the server. A self-contained component can be moved to a new page, reused in a different layout, or refactored without touching anything outside its own file. The agent doesn’t need to trace data through loaders or prop chains to understand what a component needs.

Building the App in Next.js

Now that we have components that fetch their own data and can be reused across pages without loaders, the next question is: how do we build real apps with this? Let’s say our social feed app has more than one page:

app/
  layout.tsx            // root shell: nav, sidebar
  page.tsx              // home feed
  explore/
    page.tsx            // discover feed
  post/
    [id]/
      page.tsx          // single post with replies

A component like <WhoToFollow /> works on any of these pages without the page having to fetch anything for it. The page is free to focus on what the user actually sees while things load.

From this point on, the layout markup and the sidebar live in app/layout.tsx so they wrap every page automatically.

Avoiding Blocking Renders

Server components render on the server as a stream, which means React can start sending HTML to the client before every async component has finished fetching. Suspense is what makes this work. Wrapping an async component in a Suspense boundary with a fallback tells React to send the fallback immediately while the component resolves in the background. Once it is ready, React streams the real content in and swaps it into place:

<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>

Without Suspense, the page waits for every async component to finish before sending anything. Adding a boundary is how we avoid that, and it is also what unlocks the real performance gains that Nadia’s article measures.

Making Skeletons That Stay in Sync

The fallback we pass to Suspense is what the user sees while an async component is fetching. Usually this is a skeleton: a lightweight placeholder that matches the shape of the content it stands in for, with the same dimensions and layout but no real data. Sometimes a spinner is enough instead. Either way, it is just HTML and CSS, and the goal is to avoid layout shift when the real content arrives.

To keep the skeleton in sync with its component, I like to export both from the same file:

// features/post/components/feed.tsx
export async function Feed() {
  const handle = await getCurrentUserHandle();
  const { posts } = await getFeed(handle);
  return (
    <ul className="flex flex-col gap-4">
      {posts.map(post => (
        <li key={post.id}>
          <Post post={post} />
        </li>
      ))}
    </ul>
  );
}

export function FeedSkeleton({ count = 5 }: { count?: number }) {
  return (
    <ul className="flex flex-col gap-4">
      {Array.from({ length: count }).map((_, i) => (
        <li key={i}>
          <PostSkeleton />
        </li>
      ))}
    </ul>
  );
}

PostSkeleton can use the same outer classes as Post so the box reserves the same space, with pulsing rectangles standing in for the text:

// features/post/components/post.tsx
export function Post({ post }: { post: PostT }) {
  return (
    <article className="h-24 rounded-lg border p-4">
      <p>{post.body}</p>
    </article>
  );
}

export function PostSkeleton() {
  return (
    <article className="h-24 rounded-lg border p-4">
      <div className="h-4 w-32 animate-pulse rounded bg-muted" />
    </article>
  );
}

Notice that FeedSkeleton is composed from PostSkeleton, the same way Feed is composed from Post. The skeletons mirror the component tree. When we edit Post to add a new line of metadata or change the avatar size, PostSkeleton is right there in the same file. Drift between the loading state and the rendered state, which is the most common cause of layout jank, gets caught at the time the change is made instead of in a QA pass later. An AI coding agent editing the component will see it too and remember to update the skeleton to match. When we compose a page, we know where to find the right fallback shape for each component.

Designing the Loading Experience

With Suspense and skeletons in place, the question becomes: how do we want the page to load? We could wrap the entire content area in a single boundary:

// app/page.tsx
export default function HomePage() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Suspense>
  );
}

The sidebar shows up immediately. Everything else waits behind one boundary and appears at once. Simple, but the user stares at a single skeleton until the slowest component finishes.

Or we can split the boundaries so that each section streams independently:

// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <Suspense fallback={<FeedSkeleton />}>
          <Feed />
        </Suspense>
      </main>
      <aside>
        <Suspense fallback={<TrendingTagsSkeleton />}>
          <TrendingTags />
        </Suspense>
        <Suspense fallback={<WhoToFollowSkeleton />}>
          <WhoToFollow />
        </Suspense>
      </aside>
    </>
  );
}

Now the layout shell stays static, the page header sits outside any boundary, and the feed, follow suggestions, and trending tags each resolve on their own. If the suggestions are fast and the feed is slow, the user sees suggestions first. This can also feel fragmented: three separate regions popping in at different times is not always a better experience.

We could also group the aside behind a single boundary:

// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <Suspense fallback={<FeedSkeleton />}>
          <Feed />
        </Suspense>
      </main>
      <Suspense fallback={<TrendingTagsSkeleton />}>
        <aside>
          <TrendingTags />
          <WhoToFollow />
        </aside>
      </Suspense>
    </>
  );
}

The page now loads in two groups instead of three. Notice that the fallback is only <TrendingTagsSkeleton />. TrendingTags can return a variable number of items, so we don’t know how tall it will be. If we also showed a <WhoToFollowSkeleton /> below it, the skeleton would likely be at the wrong vertical position once the real trending tags resolve. By only showing the trending tags skeleton, we avoid that mismatch. The entire aside appears at once when both components are ready.

When every component manages its own loading state on the client, the page has no say in what appears when. With Suspense, the page decides where the user waits. There is no formula for the perfect boundary placement; it comes down to trying different groupings, seeing how they feel, and iterating.

Notice how readable the page is at this point. We can look at the JSX and see exactly what renders, what shows a skeleton, and what is part of the static shell.

Modern loaders can stream too. In React Router v7, returning a promise from a loader lets that data resolve behind a Suspense boundary while the rest of the route renders. The page still receives the data as props through useLoaderData, though, so we are back to passing data down from the route boundary, which is what we are trying to avoid here.

Building a Parameterized Page

Our route tree also has a parameterized route at post/[id]/page.tsx. This page renders a single post with its replies underneath. PostDetail takes an id, fetches the post itself, and can use the same building blocks as the Post list item from the feed.

In the Next.js App Router, params is a Promise (since Next.js 15), so we need to resolve it. We could await it at the page level:

// app/post/[id]/page.tsx
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        <PostDetail id={id} />
        <section>
          <SectionHeader>Replies</SectionHeader>
          <Suspense fallback={<RepliesSkeleton />}>
            <Replies postId={id} />
          </Suspense>
        </section>
      </Suspense>
    </div>
  );
}

This works, but it makes the page async, which means it has to wait for params to resolve before rendering anything. One option is to extract a small async component whose only job is to read params:

async function PostContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <>
      <PostDetail id={id} />
      <section>
        <SectionHeader>Replies</SectionHeader>
        <Suspense fallback={<RepliesSkeleton />}>
          <Replies postId={id} />
        </Suspense>
      </section>
    </>
  );
}

That keeps the page synchronous, but adds a wrapper component just to unwrap a Promise. Instead, we can use .then() directly:

// app/post/[id]/page.tsx
export default function PostPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        {params.then(({ id }) => (
          <>
            <PostDetail id={id} />
            <section>
              <SectionHeader>Replies</SectionHeader>
              <Suspense fallback={<RepliesSkeleton />}>
                <Replies postId={id} />
              </Suspense>
            </section>
          </>
        ))}
      </Suspense>
    </div>
  );
}

The .then() resolves params so that PostDetail and Replies still receive a plain id string as a prop, and the page stays synchronous and readable. The same trick works for searchParams, which is also a Promise in Next.js 15+. This pattern also sets us up nicely for cache components later, where keeping pages synchronous matters even more.

The loading sequence follows the same thinking as on the home feed: the header is part of the static shell, the post detail streams in behind a Suspense boundary, and Replies has its own boundary inside so it can resolve independently from the post.

Adding Interactivity

The feed itself might have interactive parts: the like button on every post needs JavaScript on the client. Client components can compose the same way. Here is a LikeButton that uses a form action to call a Server Function (likePost), with useOptimistic for instant feedback:

'use client';

export function LikeButton({ postId, liked, count }: Props) {
  const [optimistic, setOptimistic] = useOptimistic({ liked, count });

  const likeAction = async () => {
    setOptimistic({
      liked: !optimistic.liked,
      count: optimistic.count + (optimistic.liked ? -1 : 1),
    });
    await likePost(postId);
  };

  return (
    <form action={likeAction}>
      <Button>{optimistic.liked ? "" : ""} {optimistic.count}</Button>
    </form>
  );
}

The form calls likePost directly across the server boundary, and useOptimistic updates the UI before the server responds.

useOptimistic is local to the component that uses it. If the update only affects the local component, that is enough. When another part of the page needs to react to the same update (a follower count, a notification badge), we can either lift the optimistic state into a context or let the framework revalidate.

Every Post in the feed composes it alongside the rest of the server-rendered content:

// features/post/components/post.tsx
export async function Post({ post }: { post: PostT }) {
  const userState = await getPostUserState(post.id);
  return (
    <article>
      <PostAuthor handle={post.authorHandle} />
      <PostBody body={post.body} />
      <LikeButton postId={post.id} liked={userState.liked} count={post.likes} />
    </article>
  );
}

My previous blog posts on server and client component composition in practice and building design components with action props using async React cover the client side of this in more depth.

Organizing the Codebase

When components are this self-contained, it becomes natural to group them by feature. A feature folder structure works well for this:

features/
  post/
    components/
      post.tsx                   // server component + skeleton
      post-detail.tsx            // server component + skeleton
      feed.tsx                   // server component + skeleton
      like-button.tsx            // client component
      replies.tsx                // server component + skeleton
  user/
    components/
      user-avatar.tsx            // server component + skeleton
      who-to-follow.tsx          // server component + skeleton

Because our components only accept minimal props like an identifier and fetch their own data, they can be picked up and composed into any page. <UserAvatar handle={handle} /> is a good example: the same component renders in the feed, on a post’s author row, next to each reply, in the follow suggestions, and in the sidebar, with nothing more than a handle. Refactoring a component to a new page doesn’t touch anything outside its feature folder.

Feature slicing is just one way to organize this. Any structure works as long as the components stay self-contained, but the reusable model maps especially well to feature folders.

Along the same lines, we can also add error handling and animations to a region by wrapping it in a React ErrorBoundary (in Next.js, catchError gives us a retry button on top of that, which I covered in Error Handling in Next.js with catchError) or in a ViewTransition to animate the content as it streams in. The page composes them around its async components.

Pulling everything from this post into one place, the home feed page might end up looking something like this:

// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <ErrorBoundary title="Failed to load feed">
          <Suspense fallback={<FeedSkeleton />}>
            <ViewTransition>
              <Feed />
            </ViewTransition>
          </Suspense>
        </ErrorBoundary>
      </main>
      <ErrorBoundary title="Failed to load suggestions">
        <Suspense fallback={<TrendingTagsSkeleton />}>
          <aside>
            <TrendingTags />
            <WhoToFollow />
          </aside>
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

Each region has its own error boundary, so a failure in one part of the page doesn’t take down the rest. The ViewTransition around the feed animates the content into place as it streams in, so the swap from skeleton to real posts feels smooth instead of abrupt.

The post detail page can follow the same pattern, with a view transition and an inner error boundary around replies so they can fail independently of the post. The outer route-level error can be handled by Next.js’s error.tsx, so we don’t need to wrap the whole page ourselves:

// app/post/[id]/page.tsx
export default function PostPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        <ViewTransition>
          {params.then(({ id }) => (
            <>
              <PostDetail id={id} />
              <section>
                <SectionHeader>Replies</SectionHeader>
                <ErrorBoundary title="Failed to load replies">
                  <Suspense fallback={<RepliesSkeleton />}>
                    <Replies postId={id} />
                  </Suspense>
                </ErrorBoundary>
              </section>
            </>
          ))}
        </ViewTransition>
      </Suspense>
    </div>
  );
}

A Note on Cache Components

With cacheComponents enabled in Next.js 16, any component that fetches dynamic data has to live behind a Suspense boundary. Everything outside those boundaries becomes part of the static shell that can be prerendered and served instantly. This enables Partial Prerendering: the static parts are served immediately, and the dynamic parts stream in. With 'use cache', we can also cache individual components or data fetches, which means some regions that previously needed a Suspense fallback can resolve instantly and the loading states disappear entirely.

The architecture we have been building throughout this post fits naturally into this model: components fetch their own data, pages place deliberate boundaries, and we choose what shows up immediately versus what streams. The .then() pattern we used on the parameterized page matters even more here, because awaiting params at the page level would pull the entire page out of the static shell and cause an error.

Building this way from the start pays off even before we turn on cacheComponents. Once we do, the architecture is already in place.

Conclusion

The trip from useEffect to React Query to loaders to RSCs has really been about getting data fetching to the server while keeping components composable. RSCs are not the only way to get there, but they compose beautifully with React’s component model, and Suspense gives us a way to design the loading experience on top of that.

If you are still reflexively writing async function Page and awaiting five queries at the top, try the inversion. Many of us learned that habit from loaders and getServerSideProps, and AI coding agents have been trained on the same patterns. Push the data fetches into the components that use them, and let Suspense handle the orchestration. The result is a codebase that is easier to read, easier to move around in, and easier for both humans and agents to work with.

To summarize the principles:

  • Pages are synchronous compositors. They don’t fetch, they compose.
  • Async components fetch their own data. Co-locate the read with the JSX.
  • Skeletons live next to their component. Same file, exported alongside it.
  • Suspense boundaries go at the page level. The page designs the loading sequence.
  • Client boundaries are leaf nodes. Push 'use client' as deep as it can go.

The demo app from this post is open source at next16-social-media if you want to explore the full codebase.

I hope this post has been helpful. Thanks to Nadia Makarevich for benchmarking RSC performance in her article, so you don’t have to take my word for it. Please let me know if you have any questions or comments, and follow me on Bluesky or X for more updates. Happy coding! 🚀

Read the whole story
emrox
3 hours ago
reply
Hamburg, Germany
Share this story
Delete

Beyond the Prompt: Claude Code

1 Share

Claude Code is one of those tools where the difference between a casual user and someone who has internalized it is enormous. The casual user types prompts, accepts suggestions, and treats it like a fancier autocomplete. The daily driver uses it like a programmable agent with memory, custom commands, parallel sessions, and a project setup that compounds over time. This guide is for the second kind of person, assuming you already know what claude does when you type it in a terminal.


1. Claude Code Beyond the Basics

Once you stop thinking of Claude Code as a prompt-and-wait chatbot and start treating it as an autonomous agent that needs guardrails, your workflow shifts. The single most important principle from Boris Cherny and the Anthropic team: give Claude a way to verify its own work. Without that, you are the only feedback loop. With it, Claude iterates until things actually work, and Boris says this alone gives a 2-3x quality improvement.

A few patterns that change how you operate day to day:

Explore, then plan, then code. Plan mode (Shift+Tab twice) puts Claude into read-only exploration. Read files, trace flows, understand the data model. Then get a plan. Then execute. Skip planning for small fixes; use it for anything touching more than one file.

Use plan mode like a design document. Have one Claude write the plan, then spin up a second Claude in a fresh session to review it as a staff engineer, with no context bias, so it actually catches gaps. If implementation goes sideways, go back to plan mode and re-plan with verification steps included.

Reference, do not describe. Instead of “look at the auth module”, type @src/auth/login.py. Instead of pasting an error, pipe it: cat error.log | claude. Exact context beats approximate description every time.

Delegate, do not pair-program. Cat Wu (Claude Code team): “The model performs best if you treat it like an engineer you’re delegating to, not a pair programmer you’re guiding line by line.” Write a crisp brief upfront, then let it run.

: Press Ctrl+G to open Claude’s plan in your editor and tweak it before Claude proceeds. The plan is just text, so shape it before it becomes code.

: When Claude makes a mistake, end your prompt with “Update CLAUDE.md so you do not repeat this.” Boris calls Claude “eerily good at writing rules for itself” from its own failures. This habit compounds more than any other in this guide.


2. The .claude Directory, Properly Understood

Most people open .claude/ once, see CLAUDE.md, and never look further. It is actually a layered configuration system.

Two scopes: Project scope lives in .claude/ inside your repo, committed to git so your team shares it. Global scope lives in ~/.claude/ and applies across every project on your machine.

Mental model: project files describe the project, global files describe you.

FileScopeCommitWhat it does
CLAUDE.mdProject and globalYesInstructions loaded every session
CLAUDE.local.mdProject onlyNo, gitignore itYour private project notes
settings.jsonProject and globalYesPermissions, hooks, env vars, model defaults
settings.local.jsonProject onlyNoPersonal overrides, auto-gitignored
.mcp.jsonProject onlyYesTeam-shared MCP servers
skills/<name>/SKILL.mdProject and globalYesReusable prompts invoked with /name
commands/*.mdProject and globalYesSingle-file slash commands
agents/*.mdProject and globalYesSubagent definitions
rules/*.mdProject and globalYesTopic-scoped instructions, optionally path-gated

A typical layout:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
my-repo/
├── .claude/
│   ├── settings.json
│   ├── agents/
│   │   ├── pr-review.md
│   │   └── test-writer.md
│   ├── skills/
│   │   └── api-conventions/SKILL.md
│   └── rules/
│       ├── frontend.md        # path-gated to src/frontend/
│       └── migrations.md      # path-gated to db/migrations/
├── CLAUDE.md                  # checked in, team-shared
├── CLAUDE.local.md            # gitignored, personal
└── .mcp.json                  # team-shared MCP servers

A few things easy to miss:

CLAUDE.md files cascade. In a monorepo, both root/CLAUDE.md and root/services/billing/CLAUDE.md load when you work in the billing service. Powerful for codebases with different conventions per folder.

rules/*.md is path-gated. Guidance specific to your migrations folder does not belong in CLAUDE.md bloating every session; it belongs in .claude/rules/migrations.md with a glob.

Skills over commands. .claude/commands/*.md and .claude/skills/<name>/SKILL.md both create slash commands, but skills support supporting files, disable-model-invocation, allowed tools, and agent overrides. New work should go in skills/.

: Run claude project purge ~/path/to/repo --dry-run to see exactly what local state Claude holds for a project, handy before handing off a laptop.


3. CLAUDE.md, The Way Boris Writes It

CLAUDE.md is loaded at the start of every session. Get it wrong and Claude repeats the same mistakes. Get it right and the same prompt produces dramatically better output.

Boris is direct about two things that matter more than the rest:

Keep it short. Long files bury important rules. For every line, ask: “Would removing this cause Claude to make a mistake?” If not, cut it.

Let Claude write rules for itself. Any time Claude does something wrong, tell it: “Update CLAUDE.md so you do not repeat this.” Claude is surprisingly good at distilling its own mistakes into precise rules. Do this for a few weeks and the file becomes a curated list of every gotcha your project has.

3.1 The Real CLAUDE.md From the Claude Code Team

Boris has shared the actual CLAUDE.md the Claude Code team checks into their own repo. The whole team contributes mulle times a week:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Development Workflow

**Always use `bun`, not `npm`.**

# 1. Make changes

# 2. Typecheck (fast)

bun run typecheck

# 3. Run tests

bun run test -- -t "test name" # Single suite
bun run test:file -- "glob" # Specific files

# 4. Lint before committing

bun run lint:file -- "file1.ts"
bun run lint

# 5. Before creating PR

bun run lint:claude && bun run test

That is the entire file. Build commands Claude cannot guess, the exact order to run things, single-test invocations, the pre-PR ritual. No style preferences. No codebase tours. No platitudes.

Boris also uses @claude in PR comments to have Claude commit a rule directly:

1
2
nit: use a string literal, not a ts enum
@claude add to CLAUDE.md to never use enums, always prefer literal unions

He calls this “Compounding Engineering,” where every PR review becomes a CLAUDE.md improvement.

A fleshed-out template following the same philosophy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Code style

- Use ES modules (import/export), not CommonJS (require)

# Workflow

- Always use `bun`, not `npm`
- Run `bun run typecheck` before claiming done
- Never push to main directly. Always open a PR.

# Architecture

- All API routes go through src/api/middleware/auth.ts
- New database queries go in src/db/queries/. No inline raw SQL.

# Gotchas

- `User` and `UserRecord` are distinct types. UserRecord is the DB row, User is the runtime object.
- `formatCurrency` assumes USD. For international use `formatCurrencyByLocale`.

The “Gotchas” section is the magic. Every entry is a mistake Claude made, captured the moment it happened.

What does not belong in CLAUDE.md: standard language conventions, file-by-file codebase descriptions, long tutorials, API docs, anything that changes frequently.

: Words like IMPORTANT or YOU MUST improve adherence. Use them sparingly so they carry weight.

You can import other files using @path syntax to keep CLAUDE.md short while pulling in details:

1
2
See @README.md for project overview and @package.json for scripts.
@~/.claude/my-preferences.md

4. CLAUDE.local.md as a Daily Driver

CLAUDE.local.md lives alongside CLAUDE.md, gets loaded the same way, but never leaves your machine. Add it to .gitignore.

The way I use it: after every PR I open, reviewers leave comments. Instead of trying to remember them, I dump them into CLAUDE.local.md the moment I see them. Over time it becomes a personalized rule file for exactly the feedback I get most often.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Personal review notes (private)

# From PR feedback

- New SQS consumers need a DLQ and alarms in the same PR
- Use `Optional<T>` over null returns
- Tests for new endpoints must include the auth-failure case
- Prefer named tuples over plain dicts for return types with 3+ fields

# My own quirks to correct

- Stop using `console.log`; use the project logger instead
- Always update the OpenAPI spec when adding endpoints

Loaded every session, Claude already knows to include auth-failure tests and update the OpenAPI spec without me mentioning it. Nitpick comments on my PRs dropped noticeably within a couple of weeks.

: Keep two sections clearly separated: project-specific feedback and personal habits to correct. Mixing them makes the file harder to prune later.

: Prune after a few weeks. Things that have become muscle memory can go. The file should capture what is still learning, not what you already do automatically.


5. Skills, In Depth

Skills let Claude Code go from “an agent that can do anything” to “an agent that does specific things really well for your project.” They are the unit of reusable expertise.

5.1 What Skills Actually Are

A skill is a folder under .claude/skills/<name>/ (project) or ~/.claude/skills/<name>/ (global) containing a SKILL.md with frontmatter and instructions. The folder name becomes the slash command.

The simplest possible skill:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
---
description: Summarizes uncommitted changes and flags anything risky. Use when the user asks what changed, wants a commit message, or asks to review their diff.
---

## Current changes

!`git diff HEAD`

## Instructions

Summarize the changes in two or three bullet points, then list any risks: missing error handling, hardcoded values, tests that need updating.

Save to ~/.claude/skills/summarize-changes/SKILL.md and /summarize-changes is available in every session.

Three things that make Skills powerful:

  • Progressive disclosure. Claude loads only frontmatter descriptions at session start (~100 tokens each). Full SKILL.md and helper files load only when the skill is actually needed.
  • Skills are folders, not files. Bundle templates, reference docs, scripts, config. SKILL.md is just the entry point.
  • Inline shell. Lines starting with ! run a command and inject the output at invocation time.

Frontmatter supports useful extras:

1
2
3
4
5
6
7
---
name: my-skill
description: When to use this skill
disable-model-invocation: true # only runs when user explicitly types /my-skill
allowed-tools: Read, Grep, Bash
agent: read-only
---

: Use disable-model-invocation: true for skills with side effects. You want /ship to deploy only when explicitly typed, not when Claude decides it is relevant.

5.2 Writing a Real Skill: Go API Conventions

A complete skill for a Go service team, covering conventions, gotchas, and scaffolding for a new HTTP handler:

1
2
3
4
5
6
.claude/skills/go-handler/
├── SKILL.md
├── templates/
│   └── handler.go.tmpl
└── examples/
    └── healthz.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
---
description: Scaffolds a new HTTP handler in our Go service following team conventions for routing, validation, error handling, and tests. Use when the user asks to add a new endpoint, a new handler, or extend an existing route group.
---

# Go HTTP Handler Skill

## Stack

- Go 1.22 with chi router
- sqlc for typed queries, never write raw SQL strings in handlers
- zap for structured logging, never fmt.Println
- testify for assertions, table-driven tests preferred

## Gotchas

- `chi.URLParam` returns `""` for missing params, not an error. Always check.
- Our `httperr.Wrap` does not log. Log separately with `h.log.Error` before returning.
- Auth middleware injects via `context.Value(authkey.User)`. Type-assert to `*models.User`.
- sqlc nullable strings use `pgtype.Text`. Check `.Valid` before calling `.String`.
- Tests must use `httptest.NewRecorder` and `httptest.NewRequest`. No real server.

A skill like this lets a new developer add a fully conventional endpoint without reading the entire codebase first.

mattpocock/skills, the most popular skills repo (~100k stars). Standouts:

  • /grill-me: interviews you about a plan before any code gets written
  • /tdd: enforces red-green-refactor strictly
  • /diagnose: disciplined debugging, reproduce, minimize, hypothesize, fix, regression test

Install: npx skills@latest add mattpocock/skills

Jeffallan/claude-skills ships 66 language-specific profiles: go-pro, python-pro, java-architect, typescript-pro, rust-engineer, sql-pro, and more. Compose them; a Next.js task pulls in nextjs-developer and typescript-pro together.

Anthropic’s official skills:

  • /code-review: four parallel agents audit the diff, confidence-scored findings only
  • /simplify: reviews recent code for reuse and efficiency
  • /batch: fans out a migration to dozens of parallel agents, each in its own worktree
  • /webapp-testing: gives Claude Playwright control to test your local web app

: If you do something more than once a day, turn it into a skill. Anything you repeat is a skill waiting to be written.

: Check skills into git. They become institutional knowledge, and new engineers clone the repo and get the team’s accumulated practices for free.


6. Building Custom Subagents

A subagent runs in its own context window with its own tool permissions and reports back a summary. It can read fifty files without filling up your main session. That is the entire value proposition.

A subagent is a markdown file under .claude/agents/ (project) or ~/.claude/agents/ (global) with a frontmatter block declaring name, description, tools, and model.

6.1 Walking Through a /pr-review Agent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
---
name: pr-review
description: Reviews the current branch diff against main, looking for bugs, security issues, missed edge cases, and project-convention violations. Use proactively before opening a PR.
tools: Read, Grep, Glob, Bash
model: opus
---

You are a senior staff engineer reviewing a pull request. Thorough, direct, goal is to catch issues before human reviewers do.

## Process

1. Run `git diff main...HEAD`
2. Run `git log main..HEAD --oneline`
3. Read full files, not just diff context
4. Cross-check against CLAUDE.md, CLAUDE.local.md, and .claude/rules/

## Flag

- Correctness bugs: off-by-one, null handling, error paths, race conditions
- Security: injection risks, missing auth checks, secrets in code
- Missing tests for new logic
- N+1 queries
- Convention violations from CLAUDE.md or rules/

## Do NOT flag

- Style preferences not in project rules
- Refactoring suggestions for working code
- Anything outside this diff

## Output

Group by severity (Critical / High / Medium / Low). File + line + issue + suggested fix.
End with a verdict: **SHIP**, **FIX FIRST**, or **REWORK**.

Run it by saying Have the pr-review agent look at my current branch. The subagent handles everything in its own context, your main session stays clean.

Key design choices: tools is read-only, because a reviewer that modifies code gets biased toward defending its own edits. model: opus for high-stakes review. The “Do NOT flag” section keeps signal-to-noise high.

The Claude Code team checks in: build-validator, code-architect, code-simplifier, oncall-guide, verify-app.

Community patterns worth adopting:

AgentWhat it does
security-reviewerinjection, auth, secrets, insecure deserialization
test-writergenerates tests, pairs with code-reviewer in a loop
debuggertraces failing tests to root causes
performance-auditorprofiles flows and queries
migration-writergenerates DB migrations matching project conventions
release-notes-writerchangelogs from commit history

Curated repos: VoltAgent/awesome-claude-code-subagents (100+ agents) and hesreallyhim/a-list-of-claude-code-agents.

: Chain agents: Session A implements, then call Use the code-reviewer subagent to check the work. The reviewer evaluates in a fresh context with no implementation bias.

: Add isolation: worktree to frontmatter to run the subagent in its own git worktree, especially powerful when fanning out a migration across dozens of parallel agents.


7. Plugins and the Marketplace

Plugins bundle skills, hooks, subagents, and MCP servers into a single installable unit. Run /plugin to open the marketplace browser. Add community marketplaces with /plugin marketplace add owner/repo.

Day-one installs:

/code-review runs four parallel agents: two audit for CLAUDE.md compliance, one scans for bugs, one analyzes git blame for context. Confidence-scored, high signal-to-noise.

/feature-dev is the most popular skill on the official marketplace. Turns a feature brief into working code through seven phases: requirements → exploration → architecture → implementation → testing → review → docs.

Language server plugin provides precise symbol navigation and automatic diagnostics after every edit. The team consistently calls this the single highest-impact plugin you can install.

/security-guidance is Anthropic’s official security skill, surfacing concerns before they ship.

Plugin categories worth knowing (1,000+ plugins across 75+ marketplaces as of mid-2026):

  • Git workflow, code intelligence (LSP), documentation generators, testing, browser automation (Playwright), design system (Figma), observability (Sentry, Datadog)

: A team-shared .mcp.json plus a few well-chosen plugins gets a new engineer productive within minutes of cloning the repo. Treat plugin choices as part of your onboarding story.


8. Underused Claude Code Commands

Most users learn /clear, /compact, and /init and stop. A handful of the rest quietly do more for productivity than anything else.

CommandWhat it does
/insightsAnalyzes your usage patterns; run once a month
/compact <hint>Compresses session; hint controls what survives
/copyCopies last response; interactive picker for code blocks
/rewindUndo for your whole session, restoring code, conversation, or both
/btwSide question that never enters conversation history
/contextVisualizes context usage
/export <file>Dumps conversation to file
/branchForks your session to try something risky
/batchFans work out to parallel agents across worktrees
/loop <interval>Schedules Claude to run on repeat, up to 3 days
/scheduleCloud version of /loop, works even when your laptop is closed
/teleportMoves a session between terminal and web
/focusHides intermediate tool calls, shows only final result
/voiceVoice input; Boris says he codes mostly by speaking
--bareUp to 10x faster startup for non-interactive claude -p usage

/compact vs /clear: genuinely new task = /clear with a fresh hand-written brief. Related task where you still need context = /compact with a hint. /compact is a lossy LLM summary; /clear is your brief. That distinction matters.

/rewind creates a checkpoint for every prompt, and those persist across sessions. When Claude goes down a wrong path, do not type “that did not work, try X,” as that pollutes context. Rewind and re-prompt with what you learned.

: Use ! as a shell escape. !git status or !npm test runs immediately with output landing in context.

: Set CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000. Context rot kicks in around 300-400k tokens on the 1M model, so force earlier compaction to stay sharp.

Fan-out pattern: generate a task list, then loop:

1
2
3
4
5
for file in $(cat files.txt); do
  claude -p "Migrate $file from React to Vue. Return OK or FAIL." \
    --allowedTools "Edit,Bash(git commit *)" \
    --bare
done

Test on three files. Fix the prompt. Then run on two thousand.

8.1 /goal, the Ralph Loop Built In

/goal sets a completion condition. Claude keeps working until the condition is true. Every time it tries to stop, it checks the condition against the transcript.

1
/goal all tests in test/auth pass and the lint step is clean

Real examples:

1
2
3
4
/goal all integration tests in tests/api pass without flaking 3 runs in a row
/goal the OpenAPI spec validates and matches the actual response shapes
/goal docker compose up runs cleanly and the healthcheck endpoint returns 200
/goal coverage on src/billing/ is above 80% and all new tests are not placeholders

Pick a verifiable, deterministic condition, tied to a test command, CLI exit code, or file state. Vague conditions like “the code is good” do not work.

Companions that pair well:

  • /loop: repeat at an interval, burn down a backlog
  • /schedule: run on a cadence in the cloud
  • A Stop hook: gate on your own test suite or CI endpoint
  • Auto mode: removes permission prompts so long goals do not stall

: Combine /goal + auto mode + /focus. Write a crisp brief, set the goal, walk away. Come back to a finished PR. This is the workflow Boris and Cat Wu push for Opus 4.7.


MCP (Model Context Protocol) turns Claude Code from a coding agent into a system-aware coding agent. An MCP server exposes external tools like a database, a design tool, your error tracker, or your notes to Claude in a standardized way.

Without MCP, Claude reads files and runs commands. With MCP, Claude reads your Linear tickets, queries your Postgres, pulls up a Figma component, fetches live Sentry stack traces, or reads your Obsidian vault, all without leaving the terminal.

The go-to MCPs for engineering work:

MCPWhat it unlocks
GitHubRepo management, PRs, issues, code search
Context7Live, up-to-date library docs; append use context7 to any prompt
SentryReal error context, stack traces, breadcrumbs
LinearRead/create tickets, update status
PlaywrightBrowser automation via accessibility snapshots
FigmaLive design tree: auto-layout, spacing tokens, component refs
Postgres / SupabaseQuery your dev DB directly
SlackRead threads, summarize discussions, draft responses

Local servers use stdio, vendor-hosted use HTTP with OAuth:

1
claude mcp add --transport http sentry <a href="https://mcp.sentry.dev/mcp" rel="nofollow">https://mcp.sentry.dev/mcp</a>

Team-shared MCPs go in .mcp.json at the project root. Personal MCPs go in ~/.claude.json.

9.1 A Real Obsidian Workflow

The Obsidian + Claude Code pairing becomes genuinely powerful when you use it as a three-tier memory architecture, not just “Claude can read my vault.”

Setup: Install obsidian-claude-code-mcp in Obsidian (exposes the vault on a local WebSocket, port 22360). Claude Code auto-discovers it. Add a CLAUDE.md to your vault explaining the folder structure.

Folder structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
vault/
├── 00-Inbox/         # raw capture
├── 10-Daily/         # one note per day
├── 20-Projects/      # active project notes
│   └── billing-v2/
│       ├── README.md      # goal, status, open questions
│       ├── decisions/     # ADRs
│       └── sessions/      # one log per Claude session
├── 30-Decisions/     # cross-project ADRs
├── 40-Atoms/         # reusable knowledge, linked
└── 90-Archive/

The three tiers:

Hot storage: daily session log. Every Claude session writes a timestamped log to 10-Daily/<today>.md. A Stop hook can do this automatically: when the agent finishes, it appends a structured summary.

Warm storage: project notes. Each project has a folder under 20-Projects/. Before any new session, Claude reads the project README and last 2-3 session logs to rebuild context. Two weeks of context in 30 seconds.

Cold storage: decisions and atoms. Architectural decisions get promoted into 30-Decisions/ as ADRs. Reusable knowledge gets distilled into 40-Atoms/ and linked via wikilinks across all projects.

Daily workflows:

  • What is in my inbox? Summarize and suggest where each item belongs.
  • Check 30-Decisions/ for anything related to retry policies.
  • Read the last 3 session logs for billing-v2. Tell me where I left off.

: Resist installing every MCP. Each one expands the tool list Claude reasons over, and bloated tool lists hurt decision quality. Starter set: GitHub, Context7, plus one or two domain-specific.

: Run /mcp inside Claude Code to list every active server and its connection status. First place to check when something is not working.


10. Optimizing Your Daily Workflow

Morning. Open Claude Code in the project. Skim what subagents and scheduled jobs did overnight. Run /insights once a week.

New feature. Plan mode → edit plan with Ctrl+G → implement → invoke /pr-review subagent or spin up a fresh Claude session to review.

Bug. Reproduce first. Pipe the error: cat error.log | claude. Ask Claude to write a failing test that reproduces it. Only then ask it to fix. The test prevents the fix from being a guess.

Migrations or mass changes. Use /batch, which interviews you about the change, then fans out to parallel agents, each in its own worktree, each testing and creating a PR.

Unfamiliar code. Use a subagent: “Use a subagent to investigate how our auth handles token refresh.” It reads dozens of files in its own context and reports back a summary. Your main session stays clean.

Parallel sessions. Boris and the team call this the single biggest productivity unlock: three to five git worktrees, each running its own Claude session. Use the agent view (claude agents) as a control plane.

Writer/Reviewer pattern. Session A implements. Session B reviews in a fresh context. Copy the review back, fix, repeat.

Compact at milestones. After finishing a logical chunk: /compact Preserve the decisions made, files changed, and test commands.

: Never let Claude claim success without evidence, whether that is tests, screenshots, or real command output. The trust-then-verify gap is the single biggest source of bad output.


11. s From the Anthropic Team

Collected from Boris, Cat Wu, Thariq, and the broader team. These are the patterns that actually change how they work:

“Give Claude a way to verify its output. Once you do that, Claude will iterate until the result is great.” Boris’s single most-repeated .

Use Opus with high or xhigh effort for almost everything. The smaller model that needs more correction is often slower overall, which is Boris’s reasoning for defaulting to Opus.

Run 3-5 sessions in parallel. Worktrees over checkouts. Use claude --worktree or the Desktop app. The agent view ties them together.

Maintain a notes directory per project, updated after every PR. Tell Claude to keep notes in a directory, point CLAUDE.md at it. The codebase compounds in self-knowledge.

Build a /techdebt slash command. Run it at the end of every session to find and kill duplicated code.

The team’s CLAUDE.md is shared and edited mulle times a week. Anytime someone sees Claude do something incorrectly, they add a rule. Treat it as a living document.

Esc twice opens rewind. Combined with checkpoints: try risky things, find out they failed, rewind cleanly.

For UI changes, set up Playwright MCP. Boris uses the Chrome extension every time he works on web code, where Claude opens a browser, clicks around, and verifies.

Install a language server plugin. Type errors and unused imports caught after every edit. Highest-impact plugin you can install.

Use /voice for prompting. You speak 3x faster than you type, and prompts get way more detailed as a result.

Auto mode + /focus + /goal. Crisp brief, set the goal, walk away. Come back to a finished PR.

Use Ctrl+G to edit Claude’s plan in your editor before implementation. Faster than typing corrections in the chat.

Ask Claude to draw ASCII diagrams of new protocols and codebases. Boris’s for understanding unfamiliar code quickly.


12. Resources

Official docs

Boris and the team

Skills

Subagents

Plugins and marketplaces

MCPs


Closing Notes

Claude Code clicked for me only after I stopped treating it like ChatGPT in a terminal. You stop thinking “I need to write this code” and start thinking “I need to set up Claude to write this code well.” The setup is the work. The execution is mostly verification.

A few things that have genuinely changed how I work:

CLAUDE.md is compounding infrastructure. Every mistake Claude makes is a rule waiting to be written. After a few weeks of “update CLAUDE.md so you do not repeat this”, the same prompts produce dramatically better output.

CLAUDE.local.md captures PR feedback. Your reviewers are giving you free training data. Convert recurring feedback into rules. Let Claude apply them next time.

Skills are the unit of reusable expertise. If you find yourself prompting the same instructions twice, that is a skill waiting to be written.

Subagents over kitchen-sink prompts. Separate concerns, keep contexts clean, and the quality of every individual task goes up.

Parallel sessions are the unlock everyone underestimates. Three Claudes in three worktrees is a different kind of leverage. Try it for a day.

The real shift happens when you stop thinking of Claude Code as a tool you use to write code and start thinking of it as something you train, configure, and operate. Most people stop at the prompts. Going past that, into the directory structure, skills, agents, plugins, and MCPs, is where it stops feeling like a tool and starts feeling like a teammate.


Claude Code icon by LobeHub, used under the Apache 2.0 license.

Read the whole story
emrox
2 days ago
reply
Hamburg, Germany
Share this story
Delete

CSS vs. JavaScript

1 Share
Introduction

One of the most common questions around animation performance is whether JS-based animations are slower than CSS-based ones. Should we always strive to use CSS transitions, or is it OK to use JavaScript animation libraries?

There’s a surprising amount of nuance to this question, and I think that the conventional wisdom isn’t quite right. In this post, we’re going to dig into this question and see the differences for ourselves!

Link to this headingComparing CSS keyframes to JavaScript loops

Let’s suppose we’re building the following animation:

We can wire this up with a CSS keyframe, like this:

@keyframes bounce {
  to {
    transform: translateX(calc(var(--bounce-magnitude) * -1));
  }
}

.ball {
  --bounce-magnitude: 200px;
  animation: bounce 1000ms infinite alternate;
}

(I’m using a CSS transform for this animation because it produces the smoothest motion. In cases where the container size is dynamic, we would need to calculate and apply --bounce-magnitude in JS.)

Alternatively, we could implement this animation using JavaScript! Before we consider JS libraries like GSAP or Motion, let’s start with a plain JS version:

const startTime = performance.now();

const ball = document.querySelector('.ball');

function animate() {
  const elapsedTime = performance.now() - startTime;

  // ✂️ Calculate `x` based on the amount of time that has passed.

  ball.style.transform = `translateX(${x}px)`;

  window.requestAnimationFrame(animate);
}

This code uses requestAnimationFrame to run the animate function on every frame (60 times per second on most displays). I’ve cut out the main logic to calculate x since it’s a bit complicated and not relevant for the topic at hand, but you can see the full code(opens in new tab) if you’re curious.

Here’s the question: which approach do you think runs more smoothly?

I think for most of us, our intuitions would tell us that the CSS version is more performant. And our intuition is correct, but maybe not for the reasons we think. 😅

You might think that the JS version is slower because it has to do all that extra work calculating the x value on every frame, or that there’s an extra cost to “crossing the bridge” between JavaScript and the DOM. But modern browser engines can tackle all of that stuff without breaking a sweat; even on low-end devices, that work happens in a tiny fraction of a millisecond, way too quick to affect the framerate of our animation.

But there’s one significant difference: the JavaScript version runs on the main thread, along with everything else happening in our application. CSS transitions and keyframe animations run on a separate thread, so they aren’t disrupted when stuff happens in JavaScript.

I’ve taken the liberty of creating a simulation. Click the “Play” button to run the demo. Every few seconds, the main thread will be blocked. Notice what effect it has on these two animations:

In modern web applications, the main thread does a lot of work. JavaScript frameworks like React are constantly making updates to the DOM, to keep it in sync with application state. Every time we make a fetch request (eg. to load more data, or to refresh existing data), that response has to be parsed by the main thread.

So, if you’ve ever seen a spinner freeze for a moment before the UI is updated, this is why! JavaScript-based animations have to compete for processing power with the rest of the application.

Link to this headingComparing animation libraries

In the example above, I used a requestAnimationFrame loop to update the UI on every frame in JavaScript. This is a pretty low-level technique; in practice, many developers use JavaScript libraries that provide a higher-level abstraction.

Let’s compare two popular animation libraries, Motion(opens in new tab) (formerly Framer Motion) and GSAP(opens in new tab):

Huh! Both Motion and GSAP are JavaScript-based, so you might expect them to share the same limitations of running on the main thread. But somehow, Motion manages to keep the animation running smoothly even when the main thread is occupied. 🤔

The secret is that Motion uses the Web Animations API(opens in new tab) (WAAPI) under the hood. WAAPI is essentially a JavaScript interface that hooks into the same low-level animation engine as CSS keyframe animations. So, Motion is able to run its animations on a separate thread, avoiding the main pitfall of most other JavaScript animation libraries! 😮

To be fair to GSAP, it’s an enormously powerful library which includes features that probably aren’t compatible with WAAPI. So, it’s not that GSAP made the wrong choice, it’s that they’re choosing different trade-offs.

In my own work, I try to use native CSS animations/transitions whenever I can. When I run into situations that CSS alone can’t handle, I try to use a library like Motion, which solves the problem without the drawbacks typically associated with JS libraries.

That said, CSS has become so powerful that there really aren’t that many cases where we need to reach for an animation library these days; new APIs like View Transitions, linear(), and Animation Timeline make it possible to do all sorts of stuff without JavaScript. ✨

We explore all of these tools, and so much more, in my brand-new course, Whimsical Animations(opens in new tab). I’ll show you how I design and implement top-tier animations using modern CSS, JavaScript, SVG, and Canvas.

Whimsical Animations, a course from Josh W. Comeau

These days, LLMs are great at generating syntax, but we still need to use our own judgment. In this blog post, we learned about the performance considerations between CSS and JavaScript, and my course is full of stuff like this. So, regardless of whether you’re writing code by hand or not, this course will help you create stunning animations and interactions.

🌸 And I’m currently running my annual Spring Sale! You can save up to $150 on the course, but only for a limited time. You can learn more here:

Last updated on

May 26th, 2026

# of hits

Read the whole story
emrox
2 days ago
reply
Hamburg, Germany
Share this story
Delete

From Rust to Ruby

1 Share

Who does things like that?!?

Apparently: me.

I have my own project, written in Rust. Not a big one, mind you, maybe approx. 30k lines of code in total. Rust is verbose so it’s not really that impressive. I’ve put it aside for some time and was toying with local inference, LLMs, writing agents and my attention was brought to Ruby.

It’s been a while. So I had to take a look around to remind myself what Ruby and Ruby on Rails are doing nowadays. They’re doing quite well. There are some typing initiatives (Sorbet), and the language itself is terse as ever.

And then I had this thought… But an introduction is in order first: In my Rust app I have an isolated crate that’s pretty much a webapp written with Tera and Axum. 14,943 lines of Rust code in total, around 10s of compilation time (maybe the code isn’t big, but it pulls the whole universe behind itself) and then quite heavy E2E tests involving setting up Playwright and (because of the near-impossibility of mocking) an isolated database namespace and mocking services (along with a very special internal-api crate that allows Playwright to interact with the app in headless mode…).

So I thought “hmm, I wonder if I can get my Local Qwen3.6 to do a oneshot conversion”. But before I did so I researched first. I asked a few instances to analyse the project in terms of gains of complexity, stability, testability, etc., and while (obviously) stability would drop (no types in Ruby) it’s not that awful (Sorbet has types in Ruby!).


 ┌─────────────────────────────────┬──────────────────┬───────┬────────────────┐
 │ Area                            │ Rust/Axum/Diesel │ Rails │ Rails + Sorbet │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Suitability for solo dev        │ 60               │ 90    │ 85             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Development speed               │ 40               │ 90    │ 75             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Safety                          │ 95               │ 55    │ 80             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Development complexity          │ 70               │ 90    │ 75             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Performance                     │ 95               │ 50    │ 50             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Boilerplate                     │ 30               │ 85    │ 80             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ E2E testing testability         │ 40               │ 75    │ 75             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Unit testability                │ 20               │ 90    │ 90             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Integration testing testability │ 30               │ 85    │ 85             │
 ├─────────────────────────────────┼──────────────────┼───────┼────────────────┤
 │ Sum                             │ 480              │ 710   │ 695            │
 └─────────────────────────────────┴──────────────────┴───────┴────────────────┘

So in the end it seems I have (licks finger and turns to the wind) 1.47x better outcomes if the app were a Ruby on Rails app instead.

interesting ai-generated image of Bugs Bunny saying i-i-i-i-i-i-nteresting

I have a local LLM running on my (bought it for gaming pre-AI craze) 4090 Ti1 - I’m a free man with unlimited tokens2. So I thought: BRING IT ON!

Since it is a relatively small project the conversion took ~30 minutes. I have no idea if it works or not because I haven’t yet tried running it. But there is one thing I checked, and stared at in horror:

$ fd . -e rs -uu | xargs cat | wc -l
   14943
$ fd . -e rb -uu | xargs cat | wc -l
    3322
ai-generated image of Bugs Bunny winning formula-1-like race with a huge 77% balloon

That’s right folks! 77% decrease in line count; 4.49 lines of Rust code for each line of Ruby.

I browsed the Ruby code and it looks… fine. There are probably some bugs (no bunnies) but I must say it’s looking clean and idiomatic for my dated eye. I’m going to examine it further with some things in mind:

  • I can add types using Agents, so probably type safety can be alleviated

  • Ruby/Rails is pretty much batteries+kitchen sink included, which beats 3GiB of compiled deps.

  • Testing will be SO MUCH EASIER

       VCR.use_cassette("llm_call") do
         result = LlmClient.match(entry, data_list)
         expect(result.results.size).to eq(data_list.size)
       end
    

    vs

    #[derive(Debug)]
    pub struct MockProvider {
      responses: Arc<RwLock<Vec<Response>>>,
      call_count: Arc<AtomicUsize>,
    }
    
    impl Default for MockProvider {
      fn default() -> Self {
        Self {
          responses: Arc::new(RwLock::new(vec![Response::default()])),
          call_count: Arc::new(AtomicUsize::new(0)),
        }
      }
    }
    
    impl MockProvider {
      pub fn new(responses: Vec<Response>) -> Self {
        Self {
          responses: Arc::new(RwLock::new(responses)),
          call_count: Arc::new(AtomicUsize::new(0)),
        }
      }
    }
    
    #[async_trait]
    impl Provider for MockProvider {
      async fn match(&self, entry: &Entry, data_list: &[Data]) -> Result<MatchResult> {
        self.call_count.fetch_add(1, Ordering::SeqCst);
        let responses = self.responses.read().await;
        Ok(MatchResult { results: responses.clone() })
      }
    }
    
    #[cfg(test)]
    mod tests {
      use super::*;
    
      #[tokio::test]
      async fn test_mock_provider_returns_expected_results() {
        let expected = vec![Response::default()];
        let provider = MockProvider::new(expected.clone());
        let result = provider.match(&Entry::default(), &[]).await.unwrap();
        assert_eq!(result.results, expected);
        assert_eq!(provider.call_count.load(Ordering::SeqCst), 1);
      }
    }
    

    …you get the vibe, right?

The benefits of working on your own project are, well, you can make crazy decisions, and I’ll be looking at this one very closely.

Read the whole story
emrox
2 days ago
reply
Hamburg, Germany
Share this story
Delete

BEMoji — The Emoji-First CSS Framework

1 Share

🎯 BEMoji v1.0.0-beta

RFC DRAFT

Block · Element · Modifier · 🎉

The CSS framework
your team will actually
argue about.

BEMoji is a production-grade utility and component framework built entirely on emoji class names. Semantic, systematic, and completely illegible to anyone without the docs.

<!-- A card component in BEMoji -->

<article class="🃏">

  <div class="🃏__🖼️ 🃏__🖼️--🌟">
    <img src="hero.jpg" alt="...">
  </div>

  <div class="🃏__📝">
    <h2 class="🃏__🔠">Card Title</h2>
    <p class="🃏__💬">Card description text</p>
  </div>

  <footer class="🃏__🦶">
    <button class="🔘 🔘--🌟">Primary</button>
    <button class="🔘 🔘--👻">Disabled</button>
  </footer>

</article>

<!-- Responsive: 📱 = mobile-only -->
<div class="📐💠 💻📐🔲">
  Grid col-1 on mobile, col-2 on desktop
</div>
/* ── Card Block ── */
.🃏 {
  border-radius: 8px;
  overflow: hidden;
  box-shadow: var(--🌑-md);
  background: var(--⬜);
}

/* Element: image */
.🃏__🖼️ {
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

/* Modifier: featured */
.🃏__🖼️--🌟 {
  outline: 3px solid var(--⭐-gold);
  outline-offset: -3px;
}

/* Design tokens use emoji too */
:root {
  --⬜: #ffffff;
  --⬛: #0d0d0f;
  --🌑-sm: 0 1px 3px rgba(0,0,0,.12);
  --🌑-md: 0 4px 12px rgba(0,0,0,.15);
  --📏-1: 0.25rem;
  --📏-4: 1rem;
  --📏-8: 2rem;
}
// bemoji.config.js
export default {
  version: '1.0',

  /** Map readable names → emoji tokens */
  blocks: {
    card:    '🃏',
    navbar:  '🧭',
    modal:   '🪟',
    alert:   '🔔',
    form:    '📋',
    table:   '📊',
  },

  elements: {
    image:   '🖼️',
    title:   '🔠',
    body:    '📝',
    footer:  '🦶',
    button:  '🔘',
    input:   '📥',
  },

  modifiers: {
    primary: '🌟',
    danger:  '🔴',
    success: '🟢',
    ghost:   '👻',
    loading: '⏳',
  },

  separator: {
    element:  '__',
    modifier: '--',
  }
};
// With the Babel transform, write readable
// BEM names — they compile to emoji at build time.

import { bem } from 'bemoji/react';

const Card = ({ featured, children }) => (
  <article className={bem('card')}>
    <div className={bem('card__image', {
      featured
    })}>
      {children.image}
    </div>
    <div className={bem('card__body')}>
      {children.body}
    </div>
  </article>
);

// Compiles to:
// className="🃏"
// className="🃏__🖼️ 🃏__🖼️--🌟"  (when featured)
// className="🃏__📝"

01 — Core Concepts

BEM, but make it
completely unreadable

BEMoji inherits BEM's three-layer architecture exactly. The only change is that every identifier — blocks, elements, modifiers, utilities, breakpoints, and design tokens — is an emoji.

🧱

Block

A standalone, self-contained component. The root of a component tree. Blocks should be reusable and carry no inherited context.

.🃏 .🧭 .📋 .🪟

🔩

Element

A part of a block that has no standalone meaning. Always expressed as block__element. Elements cannot exist outside their block.

.🃏__🖼️ .🧭__🔗 .📋__📥

🎨

Modifier

A flag that changes appearance or behavior. Applied alongside the base class using block--modifier or block__element--modifier.

.🃏--🌟 .🔘--🔴 .📥--👻

02 — Naming Anatomy

Dissecting a class name

Every BEMoji class name follows a strict, parseable structure. The double-underscore and double-hyphen delimiters are preserved from BEM, making the format machine-readable even if it isn't human-readable.

Note on escaping: Modern browsers parse emoji in CSS class selectors without escaping. For maximum compatibility (older Webkit, certain CSS-in-JS parsers), the BEMoji compiler can output escaped unicode: .🃏.\01F0CF. The toolchain handles this automatically.

Emoji Role Full class Equivalent BEM
🃏 Block: card .🃏 .card
🖼️ Element: image .🃏__🖼️ .card__image
🌟 Modifier: featured .🃏__🖼️--🌟 .card__image--featured
🔴 Modifier: danger .🔘--🔴 .button--danger
👻 Modifier: disabled .📥--👻 .input--disabled
💻🔲 Responsive utility .💻📐🔲 .lg\:grid-cols-2

03 — Emoji Vocabulary

The canonical emoji lexicon

The BEMoji spec defines 143 reserved emoji tokens across blocks, elements, modifiers, states, and design tokens. Teams can extend this with custom emoji via the config, provided they don't collide with reserved tokens.

🧱 Blocks — Components

🃏cardpanel, tile

🧭navbarnav, header

🦶footer-

📋form-

🪟modaldialog, overlay

🔔alertnotification

🏷️badgetag, chip

💬tooltippopover

📊table-

📑tabs-

🎠carouselslider

🍞breadcrumb-

🔩 Elements — Parts

🖼️imageimg, media

🔠titleheading, h

📝bodycontent, text

🦶footeractions

🔘buttonbtn, cta

📥inputfield, control

🔗linkanchor, a

🏷️label-

🎭icon-

📄itemrow, entry

🖇️dividerseparator, hr

🔭prefixprepend

🎨 Modifiers — States

🌟primaryfeatured, hero

🔴dangererror, destructive

🟢successok, valid

🟡warningcaution

🔵info-

👻disabledghost, muted

activeselected, on

loadingpending, busy

🔒lockedreadonly

💎premiumpro, upgrade

🆕newfresh, recent

🕶️darknight

🎛️ Modifiers — Size

🔬xsextra-small

🤏smsmall

⚖️mdmedium, default

🏋️lglarge

🏔️xlextra-large

🌍2xlfull, max

04 — Design Tokens

Variables, all the way down

BEMoji's token system uses emoji as CSS custom property names. This means the theme layer is as illegible as the class layer — one consistent obfuscated aesthetic throughout your entire codebase.

Color Tokens

--⬛Base dark / ink
--⬜Base light / paper
🔴--🔴Danger / error
🟢--🟢Success / ok
🟡--🟡Warning
🔵--🔵Info / accent
🟣--🟣Purple / brand
🟠--🟠Orange

Spacing Scale

📏--📏-10.25rem (4px)
📏--📏-20.5rem (8px)
📏--📏-41rem (16px)
📏--📏-61.5rem (24px)
📏--📏-82rem (32px)
📏--📏-123rem (48px)
📏--📏-164rem (64px)

Typography

✍️--✍️-xs0.75rem
✍️--✍️-sm0.875rem
✍️--✍️-base1rem
✍️--✍️-lg1.125rem
✍️--✍️-xl1.25rem
✍️--✍️-2xl1.5rem
✍️--✍️-4xl2.25rem

Shadows & Radius

🌑--🌑-smSubtle shadow
🌑--🌑-mdMedium shadow
🌑--🌑-lgLarge shadow
🌑--🌑-innerInset shadow
--⭕-smborder-radius: 4px
--⭕-mdborder-radius: 8px
--⭕-fullborder-radius: 9999px

05 — Responsive System

Breakpoints as emoji prefixes

Responsive utilities use an emoji breakpoint prefix, separated from the utility by a zero-width joiner (U+200D). This keeps class names as a single unicode "word" from the browser's perspective, while remaining visually parseable.

📱

xs

0 – 639px

Mobile first (no prefix)

📐💠

📟

sm

640px+

Large mobile / phablet

📟📐🔲

📲

md

768px+

Tablet

📲📐🔲

💻

lg

1024px+

Laptop / desktop

💻📐🔳

🖥️

xl

1280px+

Wide desktop

🖥️📐⬛

/* Generated by the BEMoji compiler */

@media (min-width: 640px) {
  .\01F4DF \01F4D0 \01F532 { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1024px) {
  .\01F4BB \01F4D0 \01F533 { grid-template-columns: repeat(3, 1fr); }
}

/* Or with modern emoji CSS (most environments) */

@media (min-width: 640px) { .📟📐🔲 { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .💻📐🔳 { grid-template-columns: repeat(3, 1fr); } }

06 — Component Library

Built-in components

BEMoji ships with 24 production-ready components pre-built in the framework stylesheet. Each follows the emoji naming convention throughout, with full modifier support.

🃏

Card Title

Supporting body text goes here

🃏 card — .🃏 .🃏__🖼️ .🃏__🔠 .🃏__📝

🌟 Primary 🔴 Danger 🟢 Success 🟡 Warning

🏷️ badge — .🏷️ .🏷️--🌟 .🏷️--🔴 .🏷️--🟢

🔘 button — .🔘 .🔘--🌟 .🔘--🔴 .🔘--👻

🔴

Something went wrong. Please try again or contact support.

🔔 alert — .🔔 .🔔--🔴 .🔔__💬 .🔔__🔘

📥 input — .📥 .📥--👻 .📥--🟢 .📥--🔴

🍞 Home / Products

🍞 Home / Cats / Tabby

🍞 breadcrumb — .🍞 .🍞__📄 .🍞__📄--✅

07 — Tooling Ecosystem

A complete dev toolchain

BEMoji ships with a full suite of build tools, editor integrations, and framework adapters. You write readable names in development; the compiler handles the emoji transformation at build time.

Transforms human-readable BEM class names in your CSS source to emoji equivalents at build time. Supports custom mappings, escaped output, and source maps.

// postcss.config.js
module.exports = {
  plugins: [
    require('bemoji-postcss')({
      config: './bemoji.config.js',
      escape: 'auto',   // 'raw' | 'unicode'
      sourceMap: true,
    })
  ]
}

Zero-config Vite integration. Transforms class names in HTML, JSX, TSX, and Vue templates. Hot module reloading works transparently with emoji class names.

// vite.config.ts
import bemoji from 'vite-plugin-bemoji';

export default {
  plugins: [
    bemoji({
      config: './bemoji.config.js',
      include: ['**/*.{tsx,jsx,vue,html}'],
    })
  ]
}

IntelliSense for BEMoji class names. Hover over an emoji class to see its human-readable equivalent. Autocomplete from your config, lint unknown tokens, and toggle between emoji/readable views.

// .vscode/settings.json
{
  "bemoji.showTooltips": true,
  "bemoji.autocomplete": "emoji+readable",
  "bemoji.lintUnknownTokens": "warn",
  "bemoji.hoverFormat": "both",
  "bemoji.configPath": "./bemoji.config.js"
}

Enforces correct BEMoji patterns. Warns on unknown emoji tokens, invalid block–element–modifier hierarchies, missing block context on elements, and disallowed multi-emoji sequences.

// .eslintrc.js
{
  "plugins": ["bemoji"],
  "rules": {
    "bemoji/no-unknown-tokens": "error",
    "bemoji/no-orphan-elements": "warn",
    "bemoji/no-modifier-without-base": "error",
    "bemoji/prefer-semantic-emoji": "warn"
  }
}

The bem() helper resolves readable block/element/modifier strings to emoji class names at runtime (or at compile time with the Babel transform). Works with conditional modifiers via object syntax.

import { bem } from 'bemoji/react';

// Runtime usage
bem('card')              // → '🃏'
bem('card__image')       // → '🃏__🖼️'
bem('card', { primary }) // → '🃏 🃏--🌟'
bem('card__image', {
  featured: true,
  loading: false         // → '🃏__🖼️ 🃏__🖼️--🌟'
})

Scaffold new projects, compile CSS, audit for unused tokens, generate a Storybook story per component, and export the full emoji→readable mapping as a JSON reference sheet.

npx bemoji init           # Scaffold + config
npx bemoji compile        # Transform CSS files
npx bemoji audit          # Check for unused tokens
npx bemoji export --fmt json > tokens.json
npx bemoji storybook      # Generate component stories
npx bemoji decode "🃏__🖼️--🌟"
# → card__image--featured

08 — Design Philosophy

Why on earth would
you do this?

BEMoji is a genuinely considered tradeoff, not a joke with no payoff. Here's the honest case for and against.

01

Free obfuscation

Production CSS with emoji class names is meaningless to scrapers, competitors, and client-side inspectors. You get the obfuscation benefits of CSS modules or hashed class names with none of the build complexity — it's just the class names themselves.

02

Enforced vocabulary

Because every UI concept maps to a single, canonical emoji, naming arguments become arguments about which emoji is most semantically correct — which is a much shorter argument than any discussion of BEM naming conventions.

03

Config is the contract

The bemoji.config.js file is a living contract between your design system and your codebase. Changing a concept's emoji is a single-line config change that propagates everywhere, unlike refactoring a string-based class name across thousands of files.

04

Developer velocity

Emoji are faster to type than long BEM class strings once you have input method shortcuts. 🃏 takes two keystrokes on most OS emoji pickers. .card__image--featured takes 24. The math eventually works out.

Honest caveat: BEMoji is not appropriate for every team or project. If your team doesn't control the config file, emoji class names become a liability rather than an asset. It also requires editor tooling buy-in from every contributor. The VS Code extension and ESLint plugin exist specifically to make this tractable.

09 — Framework Comparison

How BEMoji stacks up

Feature BEMoji Tailwind CSS BEM (vanilla) CSS Modules
Zero runtime JS
Automatic obfuscation Build-time only
Semantic naming
Responsive utilities
Design token system
Human-readable source Via build tool
Pre-built components
Bundle size ~4kb gzip ~10kb purged 0kb (DIY) 0kb (DIY)
Colleague confusion Maximum Moderate Minimal Minimal

10 — Get Started

Install BEMoji

Step 01

Install the package

npm install bemoji
# or
yarn add bemoji
# or
pnpm add bemoji

Installs the core framework, PostCSS plugin, and CLI. Peer deps are PostCSS 8+ and Node 18+.

Step 02

Initialise your config

npx bemoji init

# Creates:
# ├── bemoji.config.js
# ├── bemoji.css (base styles)
# └── postcss.config.js

The init wizard lets you pick a pre-built vocabulary or define your own token set from scratch.

Step 03

Import and build

/* main.css */
@import 'bemoji/base';
@import 'bemoji/tokens';
@import 'bemoji/components';

/* Then write your own BEM: */
.[card] { border-radius: var(--⭕-md); }
.[card__image--featured] {
  outline: 2px solid var(--🟡);
}

The PostCSS plugin compiles [card] shorthand to emoji class names automatically at build time.

Read the whole story
emrox
3 days ago
reply
Hamburg, Germany
Share this story
Delete

Agentic Patterns — Veso Research

1 Share
Research Agentic Patterns Start

The universal architecture emerging across all frontier agentic systems.

Convergence.

Claude Code, OpenAI Codex, Gemini CLI, LangGraph, CrewAI, Google ADK, Amazon Bedrock — built by different companies, in different languages, under different constraints. They converged on the same design.

Not because they copied each other. Because the constraints are physics. Finite context windows. Tools that need a protocol. Safety that can’t depend on the model obeying. Tasks too complex for a single invocation. Any team that builds long enough arrives here.


Which kind of system are you building?

The patterns in this guide apply universally, but their weight depends on which seam in the agent ecosystem you’re working on. Read in the order that matches your problem.

If you are building…You care most about…Start with
A domain context substrate (an MCP server that gives any agent structured access to one domain: a codebase, a screen, a system)Deterministic extraction, fixed ontology, behavior contracts installed at the user’s project/tool-protocols, /instructions, /anti-patterns
A personal AI runtime (an agent that the user owns, that runs in the background, with long-running state)Memory architecture, compaction-resident state, hooks, scheduler-gated background work/memory, /enforcement, /multi-agent
A multi-agent shell (an orchestrator over other people’s agents, with chat-platform reach)Adapter patterns, isolated sub-agent tool registries, settings architecture, cost controls/multi-agent, /enforcement, /cost-management

These categories aren’t airtight — many systems blur them. But knowing which one is your load-bearing concern keeps you from over-applying patterns that don’t fit your seam.


The 8 Postulates

These are not suggestions. They are the load-bearing walls of every production agentic system. Violate them and you will rediscover why they exist.

#PostulateWhat to do
1Start with a persistent instruction fileCreate a CLAUDE.md, AGENTS.md, or GEMINI.md before writing any agent config. Cover conventions, stack, testing, git, and security. Keep it under 200 lines.
2Enforce safety outside the promptPut style preferences in the instruction file. Put linting in hooks. Put destructive command blocking in permissions. Never rely on the model remembering a safety rule.
3Budget your context windowReserve 10-15% for instructions, 30-40% for conversation, 20-30% for tool results. Compact at 70%. Clear at 80%. Separate cacheable content from compactable content.
4Build tools on MCPUse .mcp.json for tool connections. 97M+ downloads/month across every major platform. If you need agent-to-agent communication across systems, add A2A — but start with MCP.
5Coordinate through shared stateWithin a system, agents read from and write to shared state — not messages to each other. Between systems or organizations, use messaging protocols (A2A). Default to state; reach for messaging only when you must.
6Decompose before you hit the cliffAgent coherence degrades after extended sessions. The threshold moves with each model generation. Don’t find the limit — stay well under it. Break work into sub-tasks that complete in the safe zone.
7Track cost per task from day oneSet token budgets per session. Route simple work to cheap models. Cache stable prompts. Monitor with alerts at 50%, 75%, and 90% of budget. Cost management is infrastructure, not optimization.
8Add complexity in weekly incrementsWeek 1: instruction file. Week 2: hooks. Week 3: MCP tools. Week 4: skills. Month 2+: sub-agents. If your team has distributed systems experience, you can move faster — but still validate each layer before adding the next.

The Architecture


Who This Is For

RoleWhat you get
Agent developersPatterns for instruction files, hooks, MCP tools, and context management.
Platform engineersMulti-agent architecture, shared state, delegation, and cost controls.
Infrastructure teamsObservability, token accounting, safety enforcement, and production runbooks.
Engineering managersAdoption roadmaps, cost models, and risk frameworks.

Reading Order

SectionKey questions answered
PromptWhat does the agent read at session start? What does the harness compile around it?
ControlHow do you bind the agent’s behavior outside the prompt?
ContextWhat does the agent remember? How do multiple agents coordinate?
InterfaceHow does the agent talk to tools, code, the web, and editors?
OperateHow do you run it in production — cost, observability, credentials, lifecycle?
Anti-PatternsWhat failure looks like — named and citable.

First agent? Start with Prompt → Control. Skip Context until one agent works reliably.

Scaling? Jump to Context and Operate. That’s where the failure modes live.

Production generative AI engineering, applied to the industries, jurisdictions, and use cases buyers actually face.

Solutions · Framing

Solutions · Delivery

Company

Read

Read the whole story
emrox
3 days ago
reply
Hamburg, Germany
Share this story
Delete
Next Page of Stories