Create Your Own tRPC Stack!

Zack DeRose - Jan 26 '23 - - Dev Community

Table of Contents

The Evolution of Awesome Tech Stacks

LAMP - the OG Tech Stack

Arguably the first tech stack that was called as such was the LAMP stack, which was traced back to a 1998 issue of the German computing magazine: Computertechnik.

LAMP here was an acronym that stood for:

L - Linux (the operating system)
A - Apache (the HTTP server)
M - MySql, or sometimes MariaDB (the database)
P - PHP / Perl / Python (the programing language)

The LAMP stack

There was something magical about this idea of a tech stack. This bundle of free-to-use, open-sourced, and relatively interchangeable software was a viable (maybe even preferable!) alternative to the many paid, proprietary, and "locking-in" packages that were popular at the time, and gave rise to some killer applications - like WordPress!

An interesting essential part of this idea of "a stack" was the concept of interchangeable pieces - note how the "P" in particular could stand for 3 VERY different programming languages, and a close cousin to LAMP was WAMP which exchanged Linux for Windows.

First-Gen JS stacks: MEAN/MERN

As we move on from the early 2000s to the 2010s - the JavaScript ecosystem, in particular, started growing in popularity, spurred by the creation of Node.js in 2009, which was accompanied by a JS package manager: npm.

With this popularity came the rise of the first all-JS stack - the MEAN stack, which stood for:

M - Mongo - a JSON (JavaScript Object Notation) based Database
E - Express - a Web Framework for Node.js
A - AngularJS - a frontend JS web framework
N - NodeJS - a backend Javascript runtime environment

The MEAN Stack

A later evolution of this stack was the MERN stack that exchanged React with AngularJS as the frontend web framework.

The MERN Stack

While this first generation of JavaScript stacks made for a recognizable and searchable term for describing a typical setup, there was little in the way of standardizing this stack.

Current-Gen JS stacks: T3, tRPC, Tanstack

Stepping into the current generation of JavaScript in the 2020s - and while we're still early in this generation of JS stacks, a clear trend can be seen in the form of a focus on tooling and the developer experience.

A stand-outs from this generation of the JS stack is the t3 stack, which is an opinionated and evolving collection of the following technologies:

Taken from https://create.t3.gg

A marked difference we can see from the MEAN/MERN stacks of the previous generation is that while MEAN/MERN operated mainly as a convenient search term to give needed context to looking up problems, the t3 stack is much more formalized, including a website, a community of support on discord and Twitter, and importantly: a CLI tool to create a working starter application.

This CLI is exciting as it is a way of standardizing and versioning the stack over time. If the community identifies a specific tool or enhancement that it can canonically bring into the stack, the "generative" nature of this tool gives developers a working starting point. Still, they can work from there to bring in their tools to further customize their stack.

The impact on the developer, however, is the ultimate goal here, as the t3 tenants of ruthless practicality:

Developers should be focused on solving their problems.

Using create-t3-app clearly demonstrates this: after answering a short series of questions, the developer has an operational full-stack application that they can see in action with one simple command to start up the project locally.

Other popular packages in this generation include the tanstack series of packages:

tanstack

and tRPC:

tRPC

While not fully-fledged stacks the way t3 is, these projects are likely successful mainly due to the attention given to tooling - particularly type safety. This positions them as obvious building blocks for other stacks to incorporate.

The Next Step: Nx

We think that Nx is uniquely positioned to be an essential tool in this current generation of JS stacks.

Nx is built on a series of core tooling features that benefit most repositories (especially monorepos), including:

  • code generation mechanisms that allow you to quickly scaffold an entire project structure or add features such as Tailwind to an existing project
  • a way of visualizing how sections of your code are connected via the project graph
  • mechanisms to ensure speed while just running commands via the nx affected command and task caching
  • a mechanism for defining how commands in your workspace depend on each other via task pipeline configuration
  • a way to easily extend tooling via a plugin mechanism

While all the above features help make things faster for any project, Nx's pluggability is particularly interesting for our discussion about stacks.

Official Nx Packages List

At Nx, we maintain many packages that are designed to be used as building blocks for developers to create their own stacks.

Framework-based packages like our React, Angular, Nest, and Node packages offer developers a way of assembling their own stack based on their preferences. For instance, adding a react application to your repository with react-router-dom installed and pre-configured to match their getting started guide is as simple as running the command:



npx nx g @nrwl/react:app my-app --routing


Enter fullscreen mode Exit fullscreen mode

Then to complement this frontend application with a backend written with NestJS, you run the command:



npx nx g @nrwl/nest:app api --frontendProject=my-app


Enter fullscreen mode Exit fullscreen mode

This will not only create a backend Nest application for you but also configure your React application to create a proxy for your API requests to this Nest application. This otherwise tedious task will often waste developer time.

Other tooling-based packages like our Vite, Webpack, and Rollup packages offer support for build, test, and Linting level tools allowing easy migration to the tool that best suits your preferences, with the added benefit of allowing for painless transitions from using webpack to vite or vice-versa.

In addition to the official Nx packages comes a registry of community-supported plugins that support other frameworks, tools, and languages that we don't have official support for:

Community Plugin Registry

These community-supplied plugins offer more building blocks to create your desired stack.

And our Nx plugin package provides an API for creating your own building blocks and composing other building blocks into a pre-defined stack - just like the t3 stack!

Building Our Concrete Example Stack

With this context in mind, let's dive into creating our concrete stack, specifically:

  • React as our frontend Application
  • Tailwind for styling on our frontend app
  • Express for our backend application
  • Vite and Vitest for frontend bundling and testing
  • Esbuild for backend building
  • tRPC for building our API and full-stack type safety across both apps

We can build upon Nx's official plugins for the first five items and write our generators for the tRPC pieces based on their "Getting Started" documentation.

The end goal is to have a codified and versioned stack that will allow us to create a full-stack application in one command and then be able to serve the entire working stack in a single command.

Here's a teaser of how this will all look when we're finished:

And you can see and clone the workspace we'll create here.

Creating our Plugin

Before starting this section, create an initial workspace using the command create-nx-workspace@latest nx-trpc-example --preset=empty, and then install our dependencies by running the commands:



> yarn add -D @nrwl/react @nrwl/vite @nrwl/node @nrwl/nx-plugin @nrwl/esbuild
> yarn add @trpc/client @trpc/server
> npx nx g @nrwl/react:init
> npx nx g @nrwl/vite:init
> npx nx g @nrwl/node:init
> npx nx g @nrwl/esbuild:init


Enter fullscreen mode Exit fullscreen mode

We'll need the above to install manually, but in the sequel to this guide, we'll create our own init generator script so that any consumers of our package won't need to run this step manually!

Next, to create our plugin, we'll run the command:



npx nx g @nrwl/nx-plugin:plugin plugin --minimal


Enter fullscreen mode Exit fullscreen mode

This will use the plugin generator of the @nrwl/plugin package to create a plugin for our workspace named: plugin. We're also using the --minimal flag here, so the plugin is empty initially.

Once this command is run, we'll see the new project in: libs/plugin

The scaffolding of this project is separated into executors and generators - where executors are an Nx mechanism to define a task (like building an app or starting a web server for local development) simplified to a simple Typescript function, and generators are an Nx mechanism for code generation simplified to a simple Typescript function. We'll see these more in the following sections, starting with generators:

Create a Full-Stack Application Generator

Nx's Generator API simplifies any code-gen script to a simple function. The @nrwl/devkit package goes on to provide utility functions to make the more burdensome things about code-generation scripts more manageable and tolerable!

When we consider at a high level what our code generation should look like, we can separate it into these four steps:

  • generate a front-end application
  • generate a corresponding backend application
  • generate a tRPC server library that is already imported into our backend application
  • generate a tRPC client library that is already imported by our frontend application

Because first-party Nx generators from the official Nx packages are functions as well, we can create a corresponding function for all four of the points above and then call those Nx generators in sequence to implement our generator.

Now that we have our roadmap for the generator, we also need to consider how we'll want to parameterize our generator. The Nx CLI uses a schema-based approach to defining these options so that when another developer uses our code generation script, they can pass any of these options to the CLI, which will be passed as parameters to our generator function we'll write next.

Given this, let's limit our options for now to the following:

  • the name of the full-stack application. This will be required, and we'll use this name to inform how we'll name the four projects to generate for this app: (${name}-web, ${name}-server, ${name}-trpc-server and ${name}-trpc-client).
  • the port for the backend and the frontend applications to run on. These won't be required - and we'll default them to 3000 and 3333, respectively.

With these in mind, we'll create our application generator using the command:



npx nx g @nrwl/nx-plugin:generator app --project=plugin


Enter fullscreen mode Exit fullscreen mode

Which will use the generator generator of the @nrwl/nx-plugin package to create our new generator named "app" located at libs/plugin/src/generators/app.

Once the command is run, you should see new files located in libs/plugin/src/generators/app, and metadata for this new app generator in libs/plugin/generators.json.

The file: libs/plugin/generators.json will define all the generators contained in our plugin to Nx - but we won't need to touch this file as the generator already took care of any changes required.

As for the other files created, we'll start with libs/plugin/src/generators/app/schema.d.ts:



export interface AppGeneratorSchema {
  name: string;
  frontendPort?: number;
  backendPort?: number;
}


Enter fullscreen mode Exit fullscreen mode

This interface matches the parameterization of the generator we want to support mentioned above. We'll also want to adjust the libs/plugin/src/generators/app/schema.json file to match this as well:

This schema file will inform all Nx tools (including the CLI validation, the CLI --help option, and the Nx Console tool) what options are available for this generator.

Notice that the name property is the only required parameter, and lines 11-14 above tell us that it is the first non-named option in our command's argument vector (or argv).



npx nx generate @nx-trpc-example/plugin:app test


Enter fullscreen mode Exit fullscreen mode

So, for example, the above command would create our new application with the name: "test", and frontendPort and backendPort would use their default values. We can add specific values for these options by providing it to our command like so:



npx nx generate @acme-dev/plugin:app test --frontendPort=3001


Enter fullscreen mode Exit fullscreen mode

Next, we can remove the libs/plugin/src/generators/app/files directory, as we won't use that mechanism for this generator.

The @nrwl/nx-plugin creates this files directory to support templating with Nx generators via the writeFiles() function created in the generator.ts file.
The generators we're creating in this article are relatively simple, so we'll use string literals when changing a file's contents.

The implementation of our generator will be written in the libs/plugin/src/generators/app/generator.ts file.

The mental model here is we will implement these steps in the following function:



import { Tree } from '@nrwl/devkit';
import { AppGeneratorSchema } from './schema';

export default async function (tree: Tree, options: AppGeneratorSchema) {
    // ... implementation to go here!
}


Enter fullscreen mode Exit fullscreen mode

Our tree parameter represents a virtual file system of the current state of our repo. We'll use this Tree API and utility functions from the @nrwl/devkit package to mutate that tree object until it matches the desired state.

You can find the entire contents of that file here, and you can see our original high-level four-step approach to the generator:



export default async function (tree: Tree, options: AppGeneratorSchema) {
  // ...
  await createReactApplication(tree, optionsWithDefaults, webAppName);
  await createNodeApplication(
    tree,
    optionsWithDefaults,
    serverName,
    webAppName
  );
  await createTrpcServerLibrary(tree, optionsWithDefaults, trpcServerName);
  await createTrpcClientLibrary(tree, optionsWithDefaults, trpcClientName);
}


Enter fullscreen mode Exit fullscreen mode

That's most of our high-level view of what a generator is and how to create it - you can skip ahead to using the generator, but otherwise - let's dive in deeper by walking through the implementation of each of these functions next:

0. Adding Default Options and Project Names

As a preliminary step, we'll define the default values for the optional parameters (lines 4-7 below). Then we'll create our optionsWithDefaults by spreading the defaultPorts with the options coming in from the command. This has the effect of overwriting the defaultPorts if the user provides their own, but otherwise, the defaults are used.

We'll also import the names function from the @nrwl/devkit package so that we can kebab-case the name provided by the user. For example, if the user-provided name is "helloWorld", the fileName of this will be "hello-world". We'll use this to get a name of our four projects in kebab-case to use later on in the generator.

Other cases supported by the names() function include:

  • pascal case
    • console.log(names('helloWorld').className); // HelloWorld
  • camel case:
    • console.log(names('helloWorld').propertyName); // helloWorld
  • screaming snake case:
    • console.log(names('helloWorld').constantName); // HELLO_WORLD
  • untouched:
    • console.log(names('uNtOuChEd').name); // uNtOuChEd

We'll use some of these later on.

1. Create Our React App

For this step, we'll create a function called createReactApplication():



async function createReactApplication(
  tree: Tree,
  options: AppGeneratorSchema,
  webAppName: string
) {
  // implementation will go here!!
}


Enter fullscreen mode Exit fullscreen mode

Our first step in this function will be to use Nx's first-party React app generator. We can import it like so:



import { applicationGenerator as reactAppGenerator } from '@nrwl/react';


Enter fullscreen mode Exit fullscreen mode

Notice we're renaming applicationGenerator to reactAppGenerator because we'll be importing other applicationGenerators in future steps!

When it comes time to call the function, we'll call it like so:

Note that Typescript Intellisense can help us with the required and optional options in lines 7-13 above!

The options we've provided will set up the application with vite and vitest, and we'll give it the appropriate port. Note that if we ever wanted to adjust our bundler to webpack in the future, making that change is as simple as changing the bundler option here!

Also note that since the reactAppGenerator we imported is an async function, we want to make sure we await it! Without awaiting, we could get a race condition that would give us interesting and undesired results.

It may be a good idea to test our generator out incrementally. For example, at this point, we can confirm that our React application is generated as expected.
To do this, I recommend using git to commit all changes you've made so far right before running the generator:



npx nx g @nx-trpc-example/plugin:app test


You can check that things look good and then reset to discard all the results so you can continue building the generator:



git add . && git reset --hard HEAD


Another tool you have for previewing changes from your generator is the --dry-run option. This will list all files that would have been created, updated, or deleted by the generator without actually running them:



npx nx g @nx-trpx-example/plugin:app test --dry-run


The Nx React plugin includes a generator for adding Tailwind to a React application, so we'll import it and call it next:



import { setupTailwindGenerator } from '@nrwl/react';

// ...

async function createReactApplication(
  tree: Tree,
  options: AppGeneratorSchema,
  webAppName: string
) {
  await reactAppGenerator(tree, {
    name: webAppName,
    linter: Linter.EsLint,
    style: 'css',
    e2eTestRunner: 'none',
    unitTestRunner: 'vitest',
    bundler: 'vite',
    devServerPort: optionsWithDefaults.frontendPort,
  });
  await setupTailwindGenerator(tree, { project: webAppName });
  // rest of implementation to come here!!
}


Enter fullscreen mode Exit fullscreen mode

Our next step is to add the boilerplate to import the tRPC client (that we'll generate in step 4!) to our new app.tsx file of our React application:



import { getWorkspaceLayout, names, Tree } from '@nrwl/devkit';

// ...

function createAppTsxBoilerPlate(tree: Tree, name: string) {
  const { className, fileName } = names(name);
  const { npmScope } = getWorkspaceLayout(tree);

  const appTsxBoilerPlate = `import { create${className}TrpcClient } from '@${npmScope}/${fileName}-trpc-client';
import { useEffect, useState } from 'react';

export function App() {
  const [welcomeMessage, setWelcomeMessage] = useState('');
  useEffect(() => {
    create${className}TrpcClient()
      .welcomeMessage.query()
      .then(({ welcomeMessage }) => setWelcomeMessage(welcomeMessage));
  }, []);
  return (
    <h1 className="text-2xl">{welcomeMessage}</h1>
  );
}

export default App;
`;
  tree.write(`apps/${fileName}-web/src/app/app.tsx`, appTsxBoilerPlate);
}


Enter fullscreen mode Exit fullscreen mode

Notice that we're anticipating our import statement to match the trpc-client library that we'll create in Step 4 and using the client to query for a welcomeMessage that we'll define in our trpc-server library in Step 3.

Also, note that we use the write method of the Tree api here to write a file to our virtual file system.

Other methods on the Tree api include:

  • read()
  • exists()
  • delete()
  • rename()
  • isFile()
  • children()
  • listChanges()
  • changePermissions()

The @nrwl/devkit also includes a list of utility functions to manipulate our tree more deftly. You can find the whole list here

We're also using the getWorkspaceLayout function of the devkit so that we can match the correct import scope that Nx uses by default for Typescript imports.

Our last step for now on the React application is to adjust the default port to match the port provided by the user:

We're also using the updateJson utility function from the @nrwl/devkit to update our React app's project.json file to change the options.port of our serve target to the provided port (line 15 above).

With these pieces now created, we can finish our createReactApplication function now:



async function createReactApplication(
  tree: Tree,
  options: AppGeneratorSchema,
  webAppName: string
) {
  await reactAppGenerator(tree, {
    name: webAppName,
    linter: Linter.EsLint,
    style: 'css',
    e2eTestRunner: 'none',
    unitTestRunner: 'vitest',
    bundler: 'vite',
    devServerPort: options.frontendPort,
  });
  await setupTailwindGenerator(tree, { project: webAppName });
  createAppTsxBoilerPlate(tree, options.name);
  adjustDefaultDevPort(tree, options);
}


Enter fullscreen mode Exit fullscreen mode

2. Create Our Backend Node App

We'll take a similar approach to create our Node application, starting by importing the @nrwl/node application generator:

Notice that we are adding a frontendProject to the options (line 19 above). This will add a proxy configuration to our React app's development server - allowing us to side-step CORS complications in our local environment.

We'll also adjust the contents of the main.ts file that is already created after the Promise returned by this nodeAppGenerator() resolves:



function createServerBoilerPlate(
  tree: Tree,
  name: string,
  backendPort: number
) {
  const { fileName } = names(name);
  const { npmScope } = getWorkspaceLayout(tree);
  const serverBoilerPlate = `/**
 * This is not a production server yet!
 * This is only a minimal backend to get started.
 */

import express from 'express';
import * as trpcExpress from '@trpc/server/adapters/express';
import { trpcRouter } from '@${npmScope}/${fileName}-trpc-server';
import { environment } from './environments/environment';

const app = express();

app.use('/api', trpcExpress.createExpressMiddleware({ router: trpcRouter }));

const port = environment.port;
const server = app.listen(port, () => {
  console.log(\`Listening at http://localhost:\${port}/api\`);
});
server.on('error', console.error);

`;
  tree.write(`apps/${name}-server/src/main.ts`, serverBoilerPlate);
  tree.write(
    `apps/${name}-server/src/environments/environment.ts`,
    `export const environment = {
  production: false,
  port: ${backendPort},
};
`
  );
}


Enter fullscreen mode Exit fullscreen mode

Note that we're anticipating an import of the trpcRouter that we'll generate in step 3, and we're also using the backendPort from our options to write this port to the environment.ts file, and thereby configure our backend port.

Putting these pieces together, our resulting function looks like so:



async function createNodeApplication(
  tree: Tree,
  options: AppGeneratorSchema,
  serverName: string,
  webAppName: string
) {
  await nodeAppGenerator(tree, {
    name: serverName,
    js: false,
    linter: Linter.EsLint,
    unitTestRunner: 'none',
    pascalCaseFiles: false,
    skipFormat: true,
    skipPackageJson: false,
    frontendProject: webAppName,
  });
  createServerBoilerPlate(tree, options.name, options.backendPort);
}


Enter fullscreen mode Exit fullscreen mode

3. Create a Library for Our tRPC Server

We'll use a similar approach with the @nrwl/js library generator for this lib:



import { libraryGenerator as jsLibGenerator } from '@nrwl/js';

// ...

async function createTrpcServerLibrary(
  tree: Tree,
  options: AppGeneratorSchema,
  trpcServerName: string
) {
  await jsLibGenerator(tree, {
    name: trpcServerName,
    bundler: 'vite',
    unitTestRunner: 'vitest',
  });
  // ... rest of the implementation to go here!
}


Enter fullscreen mode Exit fullscreen mode

After the library is generated, we'll add the boilerplate to export our trpc from the index.ts file created in this library:



function createTrpcServerBoilerPlate(tree: Tree, name: string) {
  const { className } = names(name);
  const trpcServerBoilerPlate = `import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const trpcRouter = t.router({
  welcomeMessage: t.procedure.query((req) => ({
    welcomeMessage: \`Welcome to ${name}!\`,
  })),
});

export type ${className}TrpcRouter = typeof trpcRouter;
`;
  tree.write(`libs/${name}-trpc-server/src/index.ts`, trpcServerBoilerPlate);
}


Enter fullscreen mode Exit fullscreen mode

Note that this creates the welcomeMessage query that we call from app.tsx back in step 1!

After calling these functions, we'll make a few more adjustments to clean up this library:

  • remove the ${libraryName}.ts file that was generated
  • remove the ${libraryName}.spec.ts file that was generated
  • add a sourceRoot property to our project.json file

These operations are simple enough that we can inline them in our createTrpcServerLibrary:



async function createTrpcServerLibrary(
  tree: Tree,
  options: AppGeneratorSchema,
  trpcServerName: string
) {
  await jsLibGenerator(tree, {
    name: trpcServerName,
    bundler: 'vite',
    unitTestRunner: 'vitest',
  });
  createTrpcServerBoilerPlate(tree, options.name);
  tree.delete(`libs/${trpcServerName}/src/lib/${trpcServerName}.ts`);
  tree.delete(`libs/${trpcServerName}/src/lib/${trpcServerName}.spec.ts`);
  updateJson(tree, `libs/${trpcServerName}/project.json`, (json) => ({
    ...json,
    sourceRoot: `libs/${trpcServerName}/src`,
  }));
}


Enter fullscreen mode Exit fullscreen mode

4. Create a Library for Our tRPC Client

We'll use the same approach and the same @nrwl/js library generator for this lib as well:



import { libraryGenerator as jsLibGenerator } from '@nrwl/js';

// ...

async function createTrpcClientLibrary(
  tree: Tree,
  options: AppGeneratorSchema,
  trpcClientName: string
) {
  await jsLibGenerator(tree, {
    name: trpcClientName,
    bundler: 'vite',
    unitTestRunner: 'none',
  });
  createTrpcClientBoilerPlate(tree, options.name);
  tree.delete(`libs/${trpcClientName}/src/lib/${trpcClientName}.ts`);
  updateJson(tree, `libs/${trpcClientName}/project.json`, (json) => ({
    ...json,
    sourceRoot: `libs/${trpcClientName}/src`,
  }));
}

function createTrpcClientBoilerPlate(tree: Tree, name: string) {
  const { className, fileName } = names(name);
  const { npmScope } = getWorkspaceLayout(tree);
  const trpcClientBoilerPlate = `import { ${className}TrpcRouter } from '@${npmScope}/${fileName}-trpc-server';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';

export const create${className}TrpcClient = () =>
  createTRPCProxyClient<${className}TrpcRouter>({
    links: [httpBatchLink({ url: '/api' })],
  } as any);
`;
  tree.write(
    `libs/${fileName}-trpc-client/src/index.ts`,
    trpcClientBoilerPlate
  );
}


Enter fullscreen mode Exit fullscreen mode

The client here is pretty trivial and not expected to change, so we'll use 'none' for our unitTestRunner option to avoid adding unit tests for this lib.

Putting Our Whole Generator Together

With all this in place, we're good to go! To recap, here's the entirety of our function now:



import { getWorkspaceLayout, names, Tree, updateJson } from '@nrwl/devkit';
import { applicationGenerator as nodeAppGenerator } from '@nrwl/node';
import { libraryGenerator as jsLibGenerator } from '@nrwl/js';
import {
  applicationGenerator as reactAppGenerator,
  setupTailwindGenerator,
} from '@nrwl/react';
import { AppGeneratorSchema } from './schema';
import { Linter } from '@nrwl/linter';

const defaultPorts = {
  frontendPort: 3000,
  backendPort: 3333,
};

export default async function (tree: Tree, options: AppGeneratorSchema) {
  const optionsWithDefaults = {
    ...defaultPorts,
    ...options,
  };
  const kebobCaseName = names(optionsWithDefaults.name).fileName;
  const webAppName = `${kebobCaseName}-web`;
  const serverName = `${kebobCaseName}-server`;
  const trpcServerName = `${kebobCaseName}-trpc-server`;
  const trpcClientName = `${kebobCaseName}-trpc-client`;
  await createReactApplication(tree, optionsWithDefaults, webAppName);
  await createNodeApplication(
    tree,
    optionsWithDefaults,
    serverName,
    webAppName
  );
  await createTrpcServerLibrary(tree, optionsWithDefaults, trpcServerName);
  await createTrpcClientLibrary(tree, optionsWithDefaults, trpcClientName);
}

// omitting the function implementations, but they would go below here


Enter fullscreen mode Exit fullscreen mode

Which reads like a list of exactly what we set out to do at the start!

The generators in your Local Plugin can serve as the expected scaffolding for our stack codified (as well as automated!).

This way, if you decide to alter your stack or change this scaffolding - you can adjust your generators via a Pull Request.

That pull request can serve as a discussion place to verify the change, and once merged, your stack will follow the new standard!

Use your Generator

To use the generator now, run the command:



npx nx generate @nx-trpc-example/plugin:app test


Enter fullscreen mode Exit fullscreen mode

Where @nx-trpc-example is the name of our workspace, plugin is the name of the plugin we created, app is the name of the generator we created, and test is the name of the full-stack app we want to create.

After creating our test app via the generator, we can run the command to start our server:



npx nx serve test-server


Enter fullscreen mode Exit fullscreen mode

And in a separate terminal, we can run the command:



npx nx serve test-web


Enter fullscreen mode Exit fullscreen mode

To run our react application that communicates with our server. This should give us our full-stack development environment to respond to our saves as long as these serve commands are running. But wouldn't it be great to run this in the same terminal via a single command?

In the next section, we'll create an executor that will allow us to run both of these serves in a single command.

Create an Executor

Next, we'll add a way to run both the frontend and backend applications in a development mode in a single command. As it turns out, Nx gives us a way out of the box to do this via:



% npx nx run-many --target=serve

 >  NX   Running target serve for 2 projects:

    - test-server
    - test-web

 —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————

> nx run test-server:serve


> nx run test-web:serve:development

Loading proxy configuration from: /Users/zackderose/nx-recipes/trpc-example-stack/apps/test-web/proxy.conf.json
  ➜  Local:   http://127.0.0.1:3000/
Debugger listening on ws://localhost:9229/7c8bd276-1df5-43e2-81cc-7de68cde56c4
Debugger listening on ws://localhost:9229/7c8bd276-1df5-43e2-81cc-7de68cde56c4
For help, see: https://nodejs.org/en/docs/inspector
Listening at http://localhost:3333/api
[ watch ] build succeeded, watching for changes...


Enter fullscreen mode Exit fullscreen mode

With that in place, both our client and server are started with the one command, but it's difficult to tell in the terminal which line belongs to which process, so we'll address this by creating an executor so that our resulting terminal looks like this:

Expected Terminal

(That's everything for the high-level view of understanding of executors! Feel free to skip ahead to how we can use this executor once it's created, or other stick around for an in-depth explanation of the implementation details!)

Our goal in Nx's task-running API is to simplify any task to a single function: an executor. When we use Nx to run a task (like with the command: npx nx build my-app) Nx will determine the executor function attached to my-app's build target and call that executor function!

To create an executor that will serve both the front and backend applications, we'll start by running the command:



npx nx g @nrwl/nx-plugin:executor serve-fullstack --project=plugin


Enter fullscreen mode Exit fullscreen mode

This will run the executor generator of the @nrwl/nx-plugin package to create an executor called serve-fullstack in our plugin library, with all the scaffolding managed for us.

Not unlike our generator, we can adjust the parameterization of the executor here, starting with the libs/plugin/src/executors/serve-full-stack/schema.d.ts:



export interface ServeFullstackExecutorSchema {
  frontendProject: string;
  backendProject: string;
}


Enter fullscreen mode Exit fullscreen mode

This will require us to provide the name of the frontendProject and backendProject in the options of any targets we set up for this executor. We'll also add the corresponding libs/plugin/src/executors/serve-full-stack/schema.json file:



{
  "$schema": "http://json-schema.org/schema",
  "version": 2,
  "cli": "nx",
  "title": "ServeFullstack executor",
  "description": "",
  "type": "object",
  "properties": {
    "frontendProject": {
      "type": "string",
      "description": "The name of the frontend project to serve."
    },
    "backendProject": {
      "type": "string",
      "description": "The name of the backend project to serve."
    }
  },
  "required": ["frontendProject", "backendProject"]
}


Enter fullscreen mode Exit fullscreen mode

With that setup, we'll start in the libs/plugin/src/executors/serve-fullstack/executor.ts file.

While we could take a similar approach as our generators and import the executors from our @nrwl/... packages, this is not as feasible for our use case.

Long-running executors (a task that is expected to keep running until canceled, for instance: a serve - as opposed to executors that complete at some point, like a build) return an AsyncInterator from the existing first-party Nx Executors currently, which are a bit more challenging to work with.

So instead, we'll use Node's child_process api to use Nx's CLI to call the serve's of our web app and server. Then we'll listen to emissions from these processes' stdio and stderr and log them with a good-looking prefix. In the end, it should look like this:

Starting our Server

We'll start by running the server first, as we'll need our server running for our web server's proxy to be created correctly when we start running it.

Here's the code to do so:

The startBackendServer() function will return a Promise that never resolves. This will ensure that the task will never complete (this is another way of achieving a long-running task, in addition to the AsyncIterators mentioned before).

Line 16 above creates the child process, and lines 19-20 will ensure that if our parent process ends (like if the user pressed CTRL-C while it's running), the child process will be kill()ed as well.

On lines 21-25, we'll also listen for the substring: 'build succeeded, watching for changes...' here to start up our frontend serve next!

Note the second input here: the ExecutorContext on line 9 above.

We won't use this in the generator in this example. Still, when Nx calls this function to run our executor, it will provide all of this potentially helpful context information in the second parameter here, which is useful for many other use cases!

Starting our React App

We'll use the following function to start up our frontend server:



async function startFrontendServer(frontendProject: string) {
  return new Promise(() => {
    const childProcess = exec(`npx nx serve ${frontendProject}`, {
      maxBuffer: LARGE_BUFFER,
    });
    process.on('exit', () => childProcess.kill());
    process.on('SIGTERM', () => childProcess.kill());
  });
}


Enter fullscreen mode Exit fullscreen mode

This should look very similar to the function we wrote to start our backendProject!

With this function in place, we can adjust our startBackendServer() function now to call this only the first the backend server starts correctly:

Notice we added a variable to track whether we've started the frontend server before on line 2 above, and we added a check in line 10, just before calling our startFrontendServer() function on line 11.

Adding Good-Looking Prefixes to Our logs

At this point, our executor is functional, but if we run it right now, the logs will be empty! We'll fix this by creating a function that, when given a ChildProcess and a prefix, will relay anything logged by the process to the parent's stdout.

Since we want the prefixes to look nice, later, we'll use chalk (that Nx already has as a dependency, so it's already installed) and add some logic to center the name of the project and make all prefixes take the same amount of space:

Lines 2-12 above create a reusable function to prefix every line of any incoming string and send it to the parent process's stdout by calling console.log(), and lines 13-16 set up hooks to call this function whenever the given ChildProcess writes to stdout or stderr.

The function to padTargetName on lines 19-25 above will ensure that our prefix is centered and the same length as any other project name.

All that's left is to put it all together now:

Notice lines 12-13 above determines the targeted prefix width, and we adjusted our startBackendServer() and our startFrontendServer() functions to accept this as a parameter as well, and lines 28-31 and 54-57 add our prefixTerminalOutput() so both the ChildProcesses are prefixed correctly.

Use your Executor

To use our executor, we'll start by adding a new target to our apps/test-web/project.json file:

Lines 8-14 above add a new serve-fullstack target to our test-web app that we already created with our generator. Line 9 tells it to use the executor we just wrote, and lines 10-13 give that executor the required parameters we set up for it.

All that's left to do now is call this target from the Nx CLI:



npx nx serve-fullstack test-web


Enter fullscreen mode Exit fullscreen mode

expected output

Putting it All Together

As a bonus step, now that we have our executor set up, we can automate this step of adding the serve-fullstack target to the right project.json file.

Going back to libs/plugin/src/generators/app/generator.ts, and specifically the createReactApplication() function, we can add another function called addFullStackServeTarget and call it after generating the rest of our react application:

Starting at line 26, we use @nrwl/devkit's updateJson() function to do just this to the project.json of the react app that we'll create in this generator.

As we can see in the result, the whole picture of what we've created allows us to "stamp out" any number of full stack applications to add to our monorepo's workspace, given simply a name (and optionally a frontend or backend port!). We are then fully set up to execute a full-stack serve where we can easily distinguish the logs from our frontend vs. backend serve processes, as we saw in our original teaser:

Recap

In this article, we looked at the history and evolution of tech stacks and saw how Nx is positioned to be an integral part of creating and maintaining tech stacks in the future!

While this plugin currently exists only within our current workspace that we built together, we'll follow up this blog post with another on how to publish and share your plugin with the broader community (including an init generator for installing required packages, and a preset generator for creating a new workspace from scratch!)

Learn more

If you liked this, click the ❤️ and make sure to follow Zack and Nx on Twitter for more!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .