DEV Community

Cover image for Advanced Practices for Nest.js + Next.js Projects
Alexey Yakovlev
Alexey Yakovlev

Posted on

Advanced Practices for Nest.js + Next.js Projects

This is the second and final part in a series of articles about combining nest.js and NEXT.js. In the first part we created a project and discovered a few basic SSR techniques for this stack. At this point the project can already be used as a base for developing a real website. However there are a few more things we can do: enable Hot Module Replacement for nest-next, discover more reusable SSR techniques and learn of subdirectory deployments.

This post is a translation of the original articles by me on Habr. The translation had no input from experienced tech writers or editors. So any feedback towards correcting any mistakes is greatly appreciated.

Table of Contents

Introduction

I should remind you that the final application is available at my GitHub - https://github.com/yakovlev-alexey/nest-next-example - commit history mostly follows the flow of the article. I assume that you are already finished with the first part of series and have the project at the relevant revision (commit 5139ad554f).

NEXT.js and nest.js are very similar in naming so I will be always typing NEXT.js (the React framework) in uppercase and nest.js (the MVC backend framework) in lowercase to differenciate between them more easily.

CDN deployment

Let's start simple. NEXT.js supports CDN (Content Delivery Network) deployments out of the box. Simply put your path to statics as assetPrefix in your next.config.js when building for production. Then upload ./.next/static to your CDN of choice. It is important to note that assetPrefix also has to be set in the config when launching production nest.js server.

HMR and NEXT.js instance caching

At the moment the application works really fast in dev mode. Reboots should not take more than 3 seconds and pages load up instantly. However this might not be always that way - large projects tend to have significantly crippled build procedures. It is especially noticable for large frontends. Every change to nest.js server would reboot it and create a new NEXT.js instance that would start recompiling your client (almost) from scratch.

This greatly degrades developer experience significantly increasing feedback cycles when developing complex solutions. Let's fix this issue by implementing Hot Module Replacement in nest.js and caching NEXT.js instances between reloads.

Hot Reload in nest.js

We will first follow official documentation on Hot Reload in nest.js. I will not duplicate most of information from there, just the more important parts.

For example, we should use our own start:dev script to accept a different tsconfig.json.



// ./package.json
"start:dev": "nest build --webpack --webpackPath webpack-hmr.config.js --path tsconfig.server.json --watch"


Enter fullscreen mode Exit fullscreen mode

It is important to pass tsconfig.server.json outside of a relative path: ts-loader will look up paths relative to the server executable. If supply it just with the file name it will start searching in parent folders until it finds a matching file.

Next we should tackle Hot Reload in main.ts



// ./src/server/main.ts
import { NestFactory } from  '@nestjs/core';
import { PORT } from  'src/shared/constants/env';
import { AppModule } from  './app.module';

declare const module: any;

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(PORT);

  if (module.hot) {
    module.hot.accept();
    module.hot.dispose(() => app.close());
  }
}
bootstrap();


Enter fullscreen mode Exit fullscreen mode

This implementation will works for us. However make notice of old module disposal: app.close() is called. It causes all modules, services and controller to cease.

Let's check if our server even works after adding HMR. Boot the server and make changes to app.controller.ts. It is important that the server will not reboot at all if you save the file without making any actual changes. If you did indeed make changes to a file it should take ~1-2 seconds to reboot in small projects and no more than ~4-5 seconds in large ones. It is a huge difference that significantly improve developer experience.

If any nest.js modules were using dynamic import/require they may not work after adding Webpack HMR due to bundling. My solution was to boot tsc along with Webpack nest.js executable since dynamically imported files were not frequently changed. You may also need NODE_PATH environment variable to properly resolve dynamic import paths (prepend NODE_PATH=<path> ... to start:dev script).

Caching NEXT.js instance

Still we have not solved the bottleneck we had with new NEXT.js instance for each reboot. This may take up to a few seconds in large projects during initialization and page loads.

We will cache RenderModule from nest-next between module reloads in app.module.ts For this we need nest.js dynamic module initialization.



// ./src/server/app.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import Next from 'next';
import { RenderModule } from 'nest-next';
import { NODE_ENV } from 'src/shared/constants/env';
import { AppController } from './app.controller';
import { AppService } from './app.service';

declare const module: any;

@Module({})
export class AppModule {
    public static initialize(): DynamicModule {
        /* during initialization attempt pulling cached RenderModule
            from persisted data */
        const renderModule =
            module.hot?.data?.renderModule ??
            RenderModule.forRootAsync(Next({ dev: NODE_ENV === 'development' }), {
                viewsDir: null,
            });

        if (module.hot) {
            /* add a handler to cache RenderModule
                before disposing existing module */
            module.hot.dispose((data: any) => {
                data.renderModule = renderModule;
            });
        }

        return {
            module: AppModule,
            imports: [renderModule],
            controllers: [AppController],
            providers: [AppService],
        };
    }
}


Enter fullscreen mode Exit fullscreen mode

Update main.ts to dynamically initialize AppModule.



// ./src/server/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

const app = await NestFactory.create(AppModule.initialize());


Enter fullscreen mode Exit fullscreen mode

To make sure everything works we may observe terminal outputs during nest.js reboot. If we succeeded we will not see compiling... message produced when creating a new instance of NEXT.js server. This way we potentially saved hours of developers time.

More SSR techniques

Our original problem was that we did not want to have separate services for nest.js and NEXT.js. It seems that at this point we removed all things that were blocking us from having a decent developer experience with our solution. So how about leveraging our solution to have some advantages?

Earlier we declined the strategy of returning SSR data from controller handlers. We also discovered how we can employ nest.js interceptors to pass data to GSSP in a reusable way. Why don't we use interceptors to pass some common initial data to our client? This data may be user data or tokens/translations/configurations/feature flags and so on.

Creating a configuration

In order to pass some configuration to our client we first need to create some configuration. I will not create a separate module and service for accessing configuration in nest.js (but that is how you would do it in a proper application). A simple file will suffice.

Our configuration will be populated with feature flags. As an example we will use blog_link flag toggling between two link display options.



// ./src/server/config.ts
const CONFIG = {
    features: {
        blog_link: true,
    },
};

export { CONFIG };


Enter fullscreen mode Exit fullscreen mode

Let's create ConfigInterceptor that would put configuration to returned value and include it in @UseInterceptors decorator.

Obviously in a real application you might pull feature flags from a separate service or website. Luckily it's fairly easy to access request information in interceptors: req and res HTTP contexts are available in them so you may execute some middlewares prior to executing interceptors.



// ./src/server/config.interceptor.ts
import {
    Injectable,
    NestInterceptor,
    ExecutionContext,
    CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CONFIG } from './config';

@Injectable()
export class ConfigInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        return next.handle().pipe(
            map((data) => ({
                ...data,
                config: CONFIG,
            })),
        );
    }
}


Enter fullscreen mode Exit fullscreen mode


// ./src/server/app.controller.ts
import { ConfigInterceptor } from  './config.interceptor';

// ...

@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public home() {
    return {};
}

@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor, ConfigInterceptor)
public blogPost() {
    return {};
}


Enter fullscreen mode Exit fullscreen mode

To make sure our interceptor works we may use console.log(ctx.query) in GSSP.

It is important not to include sensitive information in ctx.query: it is also serialized when generating HTML. Therefore it is also not recommended to use ctx.query to pass large items that are unused on the client. As a workaround use ctx.req to access such information: during SSR it will be populated with your Express (or fastify for that matter) req from nest.js.

AOP for GSSP

Now we have reusable code to pass information to the client: we should also add some reusable code to parse this information and do something with it. Let's create buildServerSideProps wrapper for GSSP.



// ./src/client/ssr/buildServerSideProps.ts
import { ParsedUrlQuery } from  'querystring';
import { Config } from  'src/shared/types/config';
import {
    GetServerSideProps,
    GetServerSidePropsContext,
} from  'src/shared/types/next';

type StaticProps = {
    features: Config['features'];
};

type StaticQuery = {
    config: Config;
};

const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
    getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
    return async (ctx) => {
        const { features } = ctx.query.config || {};

        const props = await getServerSideProps(ctx);

        return {
            props: {
                ...props,
                features,
            },
        };
    };
};

export { buildServerSideProps };


Enter fullscreen mode Exit fullscreen mode

If you are paying attention you may notice that buildServerSideProps recieves not a real GSSP but a simplified version that return just the props. This stops you from using redirect and other properties in individual GSSPs. To avoid that use a real GSSP as the param to buildServerSideProps.

In order to have helpful typings we should have a type for Config.



// ./src/shared/types/config.ts
export type Config = {
    features: Record<string, boolean>;
};

// ./src/server/config.ts
import type { Config } from 'src/shared/types/config';

const CONFIG: Config = {
    features: {
        blog_link: true,
    },
};


Enter fullscreen mode Exit fullscreen mode

Also NEXT.js GetServerSideProps does not quite suit us - we can't really override Query type. We will add types of our own.



// ./src/shared/types/next.ts
import {
    GetServerSidePropsResult,
    GetServerSidePropsContext as GetServerSidePropsContextBase,
} from  'next';
import { ParsedUrlQuery } from  'querystring';

export type GetServerSidePropsContext<Q = ParsedUrlQuery> = Omit<
    GetServerSidePropsContextBase,
    'query'
> & { query: Q };

export type GetServerSideProps<P, Q = ParsedUrlQuery> = (
    ctx: GetServerSidePropsContext<Q>,
) => Promise<GetServerSidePropsResult<P>>;


Enter fullscreen mode Exit fullscreen mode

Update pages to use this new wrapper.



// ./src/pages/index.tsx
export const getServerSideProps = buildServerSideProps<THomeProps>(async () => {
    const blogPosts = await fetch('/api/blog-posts');

    return { blogPosts };
});

// ./src/pages/[id].tsx
export const getServerSideProps = buildServerSideProps<TBlogProps, TBlogQuery>(
    async (ctx) => {
        const id = ctx.query.id;

        const post = await fetch(`/api/blog-posts/${id}`);

        return { post };
    },
);


Enter fullscreen mode Exit fullscreen mode

It is once again important not to pass the entire config to your client. It might contain sensitive information like tokens, internal URLs and credentials or just make your payload larger.

Accessing application context

We already can access features property on our pages. But this might not be a very comfortable way of doing so. We have to utilize prop-drilling to pass it to deeply nested components. To avoid that, use global application context.



// ./src/shared/types/app-data.ts
import { Config } from './config';

export type AppData = {
    features: Config['features'];
};

// ./src/client/ssr/appData.ts
import { createContext } from 'react';
import { AppData } from 'src/shared/types/app-data';

const AppDataContext = createContext<AppData>({} as AppData);

export { AppDataContext };


Enter fullscreen mode Exit fullscreen mode

To not repeat application context mounting on every page put this logic in _app.tsx. I will implement it using a class but it is not a requirement.



// ./src/pages/_app.tsx
import NextApp, { AppProps } from 'next/app';
import { AppDataContext } from 'src/client/ssr/appData';
import { AppData } from 'src/shared/types/app-data';

class App extends NextApp<AppProps> {
    appData: AppData;

    constructor(props: AppProps) {
        super(props);

        this.appData = props.pageProps.appData || {};
    }

    render() {
        const { Component, pageProps } = this.props;

        return (
            <AppDataContext.Provider value={this.appData}>
                <Component {...pageProps} />
            </AppDataContext.Provider>
        );
    }
}

export default App;


Enter fullscreen mode Exit fullscreen mode

Update buildServerSideProps slightly.



// ./src/client/ssr/buildServerSideProps.ts
import { AppData } from 'src/shared/types/app-data';

// ...

type StaticProps = {
    appData: Partial<AppData>;
};

// ...

return {
    props: {
        ...props,
        appData: {
            features,
        },
    },
};


Enter fullscreen mode Exit fullscreen mode

And create a hook to use AppDataContext easily.



// ./src/client/ssr/useAppData.ts
import { useContext } from 'react';
import { AppDataContext } from './appData';

const useAppData = () => {
    return useContext(AppDataContext);
};

export { useAppData };


Enter fullscreen mode Exit fullscreen mode

Finally, implement the useFeature hook and utilize it on the index page.



// ./src/client/hooks/useFeature.ts
import { useAppData } from 'src/client/ssr/useAppData';

const useFeature = (feature: string, defaultValue = false) => {
    return useAppData().features[feature] ?? defaultValue;
};

export { useFeature };

// ./src/pages/index.tsx
const Home: FC<THomeProps> = ({ blogPosts }) => {
    const linkFeature = useFeature('blog_link');

    return (
        <div>
            <h1>Home</h1>
            {blogPosts.map(({ title, id }) => (
                <div key={id}>
                    {linkFeature ? (
                        <>
                            {title}
                            <Link href={`/${id}`}> Link</Link>
                        </>
                    ) : (
                        <Link href={`/${id}`}>{title}</Link>
                    )}
                </div>
            ))}
        </div>
    );
};


Enter fullscreen mode Exit fullscreen mode

Validate the changes in the browser. localhost:3000 should display links according to the feature flag we set in the server configuration. Update the configuration and check if after page refresh displayed link changes.

Client-side navigation

After makes such significant changes to our SSR pipelines and GSSP we surely have to check if client-side navigations still work.

Our concerns are justified: browsers will reload the page when making client-side transitions. In the terminal we would see an error: we can't quite serialize appData.features field. It expectedly is undefined when making client-side navigation. Our nest.js controller handlers do not get called when making client-side transitions, remember?

Only apply interceptors or req properties to pass actual initial data. And never expect them to be available in GSSP functions. Such data may be translations, feature flags, CSRF tokens and other configurations.

To solve our current issue let's sanitize buildServerSideProps return values.



// ./src/client/ssr/filterUnserializable.ts
const filterUnserializable = (
    obj: Record<string, unknown>,
    filteredValues: unknown[] = [undefined],
): Record<string, unknown> => {
    return Object.keys(obj).reduce<Record<string, unknown>>((ret, key) => {
        if (typeof obj[key] === 'object' && obj[key] !== null) {
            return {
                ...ret,
                [key]: filterUnserializable(obj[key] as Record<string, unknown>),
            };
        } else if (!filteredValues.includes(obj[key])) {
            return { ...ret, [key]: obj[key] };
        }

        return ret;
    }, {});
};

export { filterUnserializable };

// ./src/client/ssr/buildServerSideProps
import  {  filterUnserializable  }  from  './filterUnserializable';

// ...

return {
    props: {
        ...(await getServerSideProps(ctx)),
        appData: filterUnserializable({ features }) as StaticProps['appData'],
    },
};


Enter fullscreen mode Exit fullscreen mode

This filterUnserializable implementation has some flaws. It will show poor perfomance for large and nested objects. For completeness and consistency between article versions I will leave the code as is to later return in a separate article on how to diagnose and profile such issues.

Let's test client-side transitions once again - should work now.

Having the ability to easily pass arbitary data to your client is one of the primary advantages nest-next offers in my opinion. Data may come from any source including middlewares mutating req properties. You may leverage existing Express solutions in your nest-next project and deliver changes to clients almost immediately.

Subdirectory deployment

In some cases you might want your production application to be deployed in a subdirectory. For example we are working on documentation and our service should be available at /docs. Or with our current application - a blog section on a website with /blog prefix.

What should we do to support such functionality? It seems the only thing blocking us is API requests and client links. Statics will be deployed on a CDN so it will not be a problem. Seems like a fairly simple task at first.

But then we remember that NEXT.js makes requests to an internal endpoint when making client-side transitions. Request handlers executes GSSP on the server and return JSON with data. We are rather helpless when trying to change this. And if we do not use a CDN the entire static will break. This will not do for us.

Luckily in NEXT.js docs we find basePath parameter. It allows NEXT.js to support subdirectory deployments out of the box, adding it to every NEXT.js server endpoint. Great, now we have a plan, let's start.

Development proxy

Before we start coding anything we should provide a way to reproduce our production environment with subdirectory deployment. For this we will use a simple proxy server. Use Docker and nginx to implement and start a proxy. Let's add proxy configuration.



# ./nginx.conf
server {
    listen 8080;

    location /blog/ {
        proxy_pass http://localnode:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
}


Enter fullscreen mode Exit fullscreen mode

It is important to understand that nginx will boot in a Docker container with a net of its own. Therefore to properly proxy the request to the nest.js server we would need an address for the host machine. We will use localnode name for it. I have not tested start:proxy script on Windows machines. Unless you are using WSL you may have some issues supplying the container with host machine address.

This configuration will allow proxy to handle requests to localhost:8080/blog/* and proxy them to localhost:3000/*. In package.json add start:proxy script to boot a Docker container with our proxy.



docker run --name nest-next-example-proxy \
    -v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
    --add-host localnode:$(ifconfig en0 | grep inet | grep -v inet6 | awk '{print $2}') \
    -p 8080:8080 \
    -d nginx


Enter fullscreen mode Exit fullscreen mode

Adding basePath

Add basePath to your server configuration. Update typings and pull data from BASE_PATH environment variable.



// ./src/shared/types/config.ts
export type Config = {
    features: Record<string, boolean>;
    basePath: string;
};

// ./src/server/config.ts
import { Config } from 'src/shared/types/config';

const CONFIG: Config = {
    features: {
        blog_link: true,
    },
    basePath: process.env.BASE_PATH || '',
};

export { CONFIG };


Enter fullscreen mode Exit fullscreen mode

Create next.config.js to configure NEXT.js. Put basePath there as well.



// ./next.config.js
module.exports = {
    basePath: process.env.BASE_PATH,
};


Enter fullscreen mode Exit fullscreen mode

Proxy and basePath

Check website on localhost:8000/blog after booting both server and proxy. The request reaches nest.js server but NEXT.js static requests are unsuccessful. NEXT.js expects that the request comes with the desired basePath in req.url. But in our nginx proxy we cut it off. Add a separate rule to proxy /blog/_next requests without replacing our basePath.



server {
    listen 8080;

    location /blog/_next/ {
        proxy_pass http://localnode:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /blog/ {
        proxy_pass http://localnode:3000/;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
}


Enter fullscreen mode Exit fullscreen mode

Reboot Docker container and check the website in your browser. Unfortunately we are out of luck again.

There was (and still is to some extent) a problem with nest-next. This package applies a filter that forwards unhandled 404s to NEXT.js. But only if the request starts with /_next. Therefore NEXT.js server expects a request that starts with basePath and nest-next proxies only requests starting with /_next.

I submitted a PR to fix this issue and it was merged. However package maintainer Kyle has not published a new version with this fix since then. You may ask him to publish or use a compiled version from my GitHub.



yarn upgrade nest-next@https://github.com/yakovlev-alexey/nest-next/tarball/base-path-dist


Enter fullscreen mode Exit fullscreen mode

Tag base-path-dist only includes the required files.

After bumping npm package check localhost:8080/blog in your browsers. You should finally see a familiar Home page. Client-side navigations also work!

Fetch wrapper

The only thing left is to add basePath to fetch requests. It seems that there is not need for that at the moment. However that is only due to the fact that we call fetch only on the server and never from the client. As soon as you start fetching from the client you start seeing errors.

Let's refactor buildServerSideProps a little. Update typings for AppData and extract appData parsing from ctx.query to a separate method.



// ./src/shared/types/app-data.ts
import { Config } from './config';

export type AppData = Pick<Config, 'basePath' | 'features'>;

// ./src/client/ssr/extractAppData.ts
import { GetServerSidePropsContext } from 'src/shared/types/next';
import { AppData } from 'src/shared/types/app-data';
import { filterUnserializable } from './filterUnserializable';
import { StaticQuery } from './buildServerSideProps';

const extractAppData = (
    ctx: GetServerSidePropsContext<Partial<StaticQuery>>,
) => {
    const { features, basePath } = ctx.query.config || {};

    return filterUnserializable({ features, basePath }) as Partial<AppData>;
};

export { extractAppData };


Enter fullscreen mode Exit fullscreen mode

Use new util in buildServerSideProps.



// ./src/client/ssr/buildServerSideProps.ts
import { extractAppData } from './extractAppData';

// ...

const buildServerSideProps = <P, Q extends ParsedUrlQuery = ParsedUrlQuery>(
    getServerSideProps: (ctx: GetServerSidePropsContext<Q>) => Promise<P>,
): GetServerSideProps<Partial<StaticProps> & P, Partial<StaticQuery> & Q> => {
    return async (ctx) => {
        const props = await getServerSideProps(ctx);

        return {
            props: {
                ...props,
                appData: extractAppData(ctx),
            },
        };
    };
};

export { buildServerSideProps };


Enter fullscreen mode Exit fullscreen mode

Finally we may access basePath on the client. The only thing left is to add this property to the actual fetch wrapper. I will not create an ingenious solution and turn envAwareFetch into a function with side effects.



// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';

type FetchContext = {
    basePath: string;
};

const context: FetchContext = {
    basePath: '',
};

const initializeFetch = (basePath: string) => {
    context.basePath = basePath;
};

const getFetchUrl = (url: string) => {
    if (isServer) {
        // на сервере не нужно добавлять basePath - запрос делается не через proxy
        return url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;
    }

    return url.startsWith('/') ? context.basePath + url : url;
};

const envAwareFetch = (url: string, options?: Partial<RequestInit>) => {
    const fetchUrl = getFetchUrl(url);

    return fetch(fetchUrl, options).then((res) => res.json());
};

export { envAwareFetch as fetch, initializeFetch };


Enter fullscreen mode Exit fullscreen mode

Finally initialize fetch using initializeFetch. It might seem that we should do that in GSSP however it is once again only run on the server. Therefore use _app.tsx as the place to initialize fetch.



// ./src/pages/_app.tsx
constructor(props: AppProps) {
    super(props);

    this.appData = props.pageProps.appData || {};

    initializeFetch(this.appData.basePath);
}


Enter fullscreen mode Exit fullscreen mode

To check that everything works properly you add a fetch request in useEffect on one of the pages.

This way we can deploy nest-next application in subdirectories without loosing any of the features or advantages we had.

Proxy without rewrites

There is also an option to use a proxy without rewrites. Then all requests have to be handled with basePath on the server as well. Proxy configuration would look like this.



server {
    listen 8080;

    location /blog/ {
        proxy_pass http://localnode:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
    }
}


Enter fullscreen mode Exit fullscreen mode

To prepend basePath to every nest.js server endpoint you may employ something like nest-router and use basePath as path parameter.

It is also important to initialize fetch before making any requests in GSSP. And you would have to pull basePath from an environment variable or directly from config when running GSSP on client-side transition.

Conclusion

Articles summary as a miro board

nest-next allows you to have very reusable SSR pipelines with all the features of NEXT.js and existing infrastructure you might have with nest.js and Express. Hopefully this series of articles helped you to better understand how you might leverage this technology to help you develop large applications. Skills you might get from completing this tutorial can also be reused for configuring nest.js with other purposes.

Top comments (3)

Collapse
 
lschulzes profile image
Lucas Schulze • Edited

Out of several cons, the pros are really charming, being able to share the code between both frameworks is good, and allows for a more integrated dev experience (for a full-stack) and the requests made of localhost take a lot less than it would having a decoupled front/back.

Collapse
 
umasankarswain profile image
umasankar-swain
Collapse
 
bluesky49 profile image
bluesky49

Thank you for your good article.
one question: I tried to use global.css to use tailwind css but it is not working.
Do you have any idea?