In Modern.js, SSR also works out of the box. Developers do not need to write complex server level logic for SSR, nor do they need to care about the operation and maintenance of SSR, or create services. Modern.js have a comprehensive SSR degradation strategy to ensure that pages can run safely.
Enabling SSR is very easy, just set 'server.ssr' to true
:
import { defineConfig } from '@modern-js/app-tools';
export default defineConfig({
"server": {
"ssr": true,
},
})
Modern.js provides Data Loader, which is convenient for developers to fetch data under SSR and CSR. Each routing module, such as layout.tsx
and page.tsx
, can define its own Data Loader:
export const loader = () => {
return {
message: 'Hello World',
};
};
in the component, the data returned by the loader
function can be get data through the Hooks API:
export default () => {
const data = useLoaderData();
return <div>{data.message}</div>;
};
Modern.js break the traditional SSR development model and provide users with a user-friendly SSR development experience.
And it provides elegant degradation processing. Once the SSR request fails, it will automatically downgrade and restart the request on the browser side.
However, developers still need to pay attention to the fallback of data, such as null
values or data returns that do not as expect. Avoid React rendering errors or messy rendering results when SSR.
INFO
When you request the page on client-side page transitions, Modern.js sends an API request to the server, which runs Data Loader function.
When using Data Loader, data fetching happens before rendering, Modern.js still supports fetching data when the component is rendered. See Data Fetch. :::
In some businesses, it is usually necessary to display different UI displays according to the current operating container environment characteristics, such as UA information.
If the processing is not careful enough, the rendering results may do not meet the expectations at this time.
Here is an example to show the problem when SSR and CSR rendering are inconsistent, add the following code to the component:
{
typeof window !== 'undefined' ? <div>browser content</div> : null;
}
After starting the app, visit the page and will find that the browser console throws a warning message:
Warning: Expected server HTML to contain a matching <div> in <div>.
This is caused by the inconsistency between the rendering result and the SSR rendering result when React executes the hydrate logic on the client side. Although the page performs normally, in complex applications, it is likely to cause problems such as DOM hierarchy confusion and style confusion.
:::info For hydrate logic, please refer to here.
Applications need to maintain the consistency of SSR and CSR rendering results. If there are inconsistencies, it means that this part of the content does not need to be rendered in SSR.
Modern.js provide <NoSSR>
for such content that does not need to be rendered in SSR:
import { NoSSR } from '@modern-js/runtime/ssr';
Outside of elements that do not require SSR, wrap with a NoSSR
component:
<NoSSR>
<div>client content</div>
</NoSSR>
After modifying the code, refresh page found that the previous Waring disappeared. Open the Network window of the browser developer tool to see that the returned HTML document does not contain the content of the NoSSR
component package.
INFO
'useRuntimeContext' can get complete request information, which can be used to ensure that the rendering results of SSR and CSR are consistent.
WARNING
In the SSR, developers need to pay special attention to the problem of memory leaks. Even small memory leaks can affect services..
In SSR, every request triggers the component rendering. So, you need to avoid defining any potentially growing global data, or subscribing to events globally, or creating streams that will not be destroyed.
For example, the following code, when using redux-observable, developers used to code like this:
import { createEpicMiddleware, combineEpics } from 'redux-observable';
const epicMiddleware = createEpicMiddleware();
const rootEpic = combineEpics();
export default function Test() {
epicMiddleware.run(rootEpic);
return <div>Hello Modern.js</div>;
}
Create a Middleware instance epicMiddleware
outside the component and call epicMiddleware.run inside the component.
On the browser side, this code does not cause any problems. But in SSR, the Middleware instance will never be destroyed. Every time the component is rendered and rootEpic
is called, new event bindings are added internally, causing the entire object to continue to grow larger, which ultimately affects application performance.
Such problems in CSR are not easy to detect, so when switching from CSR to SSR, if you are not sure whether the application has such hidden dangers, you can press the application.
In order to keep the data requested in the SSR phase, it can be used directly on the browser side, Modern.js inject the data and state that collected during the rendering process into the HTML.
As a result, CSR applications often have a large amount of interface data and the state of the components is not crop. If SSR is used directly, the rendered HTML size may be too large.
At this time, SSR not only cannot bring an improvement in the user experience, but may have the opposite effect.
Therefore, when using SSR, developers need to do reasonable weight loss for the application:
Modern.js provide Serverless Pre-rendering(SPR) to improve SSR performance.
SPR uses pre-rendering and caching to provide the responsive performance of static Web for SSR pages. It allows SSR applications to have the responsiveness and stability of static Web pages, while keeping data dynamically updated.
Using SPR in the Modern.js is very simple, just add the <PreRender>
component, and the page where the component is located will automatically open SPR.
This mock a component that uses the useLoaderData
API, and the request in the Data Loader takes 2s.
import { useLoaderData } from '@modern-js/runtime/router';
export const loader = async () => {
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve(null);
}, 2000);
});
return {
message: 'Hello Modern.js',
};
};
export default () => {
const data = useLoaderData();
return <div>{data?.message}</div>;
};
After executing the dev
command and opening the page, it is obvious that the page needs to wait 2s before returning.
The next is to use the <PreRender>
component, which can be exported directly from @modern-js/runtime/ssr
:
import { PreRender } from '@modern-js/runtime/ssr';
Use the PreRender
component in the routing component and set the parameter interval
to indicate that the expiration time of the rendering result is 5s:
<PreRender interval={5} />
After modification, execute pnpm run build && pnpm run serve
to start the application and open the page.
When open page for the first time, it is no different from the previous rendering, and there is also a 2s delay.
Refresh page, and the page opens instantly, but at this time, the page data has not changed due to the refresh, because the cache has not expired.
Wait 5s, refresh the page again, the data of the page is still unchanged. Refresh the page again The data changes, but the page still responds almost instantaneously.
This is because in the previous request, the SPR has asynchronously obtained the new rendering result in the background, and the page requested this time is the version that has been cached in the server.
It is conceivable that when interval
is set to 1, users can have the responsive experience of a static page.
INFO
For more detail, see <PreRender>
.
When SSR is enabled, Modern.js will use the same entry to build both the SSR Bundle and the CSR Bundle. Therefore, the Web API in the SSR Bundle, or the Node API in the CSR Bundle, can lead to runtime errors.
Web API into a component is usually to do some global listening, or to get browser-related data, such as:
document.addEventListener('load', () => {
console.log('document load');
});
const App = () => {
return <div>Hello World</div>;
};
export default App;
The Node API is introduced in the component file, usually because of the use of Data Loader, for example:
import fse from 'fs-extra';
export const loader = () => {
const file = fse.readFileSync('./myfile');
return {
...
};
};
For the first case, we can directly use Modern.js built-in environment variables MODERN_TARGET
to remove useless code at build time:
if (process.env.MODERN_TARGET === 'browser') {
document.addEventListener('load', () => {
console.log('document load');
});
}
NOTE
For more information, see environment variables.
In the second case, the Treeshaking method does not guarantee that the code is completely separated. Modern.js also supports the packaging file of SSR Bundle and CSR Bundle products through the file suffixed with .node.
.
For example, the import of fs-extra
in the code, when it is directly referenced to the component, will cause the CSR to load an error. You can create .ts
and .node.ts
files of the same name as a layer of proxy:
export const readFileSync: any = () => {};
export { readFileSync } from 'fs-extra';
use ./compat
directly into the file. At this time, files with the .node.ts
suffix will be used first in the SSR environment, and files with the .ts
suffix will be used in the CSR environment.
import { readFileSync } from './compat'
export const loader = () => {
const file = readFileSync('./myfile');
return {
...
};
};
Both of the above methods will bring some burden to the developer. Modern.js based on Nested Routing developed and designed Data Fetch to separate CSR and SSR code.
When initiating remote requests in SSR, developers sometimes use request tools. Some interfaces need to pass user cookies, which developers can get through the 'useRuntimeContext' API to achieve.
It should be noted, the request header of the HTML request is obtained, which may not be applicable to remote requests, so must not pass through all request headers.
In addition, some backend interfaces, or general gateways, will verify according to the information in the request header, and full pass-through is prone to various problems that are difficult to debug. It is recommended that pass-through on demand.
Be sure to filter the host
field if you really need to pass through all request headers.
Modern.js supports streaming rendering in React 18. Opt in it with the following configuration:
import { defineConfig } from '@modern-js/app-tools';
export default defineConfig({
"server": {
"ssr": {
"mode": "stream",
},
},
})
The streaming SSR of Modern.js is implemented based on React Router, and the main APIs involved are:
defer
: This utility allows you to defer values returned from loaders by passing promises instead of resolved values.Await
: Used to render deferred values with automatic error handling.useAsyncValue
: Returns the resolved data from the nearest <Await>
ancestor component.import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router';
interface User {
name: string;
age: number;
}
export interface Data {
data: User;
}
export default ({ params }: LoaderFunctionArgs) => {
const userId = params.id;
const user = new Promise<User>(resolve => {
setTimeout(() => {
resolve({
name: `user-${userId}`,
age: 18,
});
}, 200);
});
return defer({ data: user });
};
user
is of Promise
type, which means the data will be obtained asynchronously. Note that defer
must accept an object type parameter,
therefore, the parameter passed to defer
is {data: user}
.
defer
can also receive asynchronous data and synchronous data at the same time. For example:
// skip some codes
export default ({ params }: LoaderFunctionArgs) => {
const userId = params.id;
const user = new Promise<User>(resolve => {
setTimeout(() => {
resolve({
name: `user-${userId}`,
age: 18,
});
}, 200);
});
const otherData = new Promise<string>(resolve => {
setTimeout(() => {
resolve('some sync data');
}, 200);
});
return defer({
data: user,
other: await otherData
});
};
await
is added before otherData
, so the data is obtained synchronously. It can be passed to defer
with the data user
at the same time.
Use the Await
component to render the data returned asynchronously from the Data Loader. For example:
import { Await, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
import type { Data } from './page.loader';
const Page = () => {
const data = useLoaderData() as Data;
return (
<div>
User info:
<Suspense fallback={<div id="loading">loading user data ...</div>}>
<Await resolve={data.data}>
{(user) => {
return (
<div id="data">
name: {user.name}, age: {user.age}
</div>
);
}}
</Await>
</Suspense>
</div>
);
};
export default Page;
Await
needs to be wrapped inside the Suspense
component. The resolve
of Await
passes in the data acquired asynchronously by the Data Loader. When the data acquisition is completed,
the obtained data is rendered through the Render Props mode. When the data acquisition is in pending status, the
content set by the fallback
property of the Suspense
component will display.
Warning
When importing a type from a Data Loader file, you need to use the import type
syntax to ensure that only type information is imported, which can prevent the Data Loader code from being packaged into the client bundle.
import type { Data } from './page.loader'
;
You can also get the asynchronous data returned by Data Loader through useAsyncValue
. For example:
import { useAsyncValue } from '@modern-js/runtime/router';
// skip some codes
const UserInfo = () => {
const user = useAsyncValue();
return (
<div>
name: {user.name}, age: {user.age}
</div>
)
}
const Page = () => {
const data = useLoaderData() as Data;
return (
<div>
User info:
<Suspense fallback={<div id="loading">loading user data ...</div>}>
<Await resolve={data.data}>
<UserInfo />
</Await>
</Suspense>
</div>
);
};
export default Page;
The errorElement
property of the Await
component can be used to handle errors thrown when the Data Loader executes or when a child component renders.
For example, we intentionally throw an error in the Data Loader function:
import { defer } from '@modern-js/runtime/router';
export default () => {
const data = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('error occurs'));
}, 200);
});
return defer({ data });
};
Then use useAsyncError
to get the error, and assign the component used to render the error to the errorElement
property of the Await
component:
import { Await, useAsyncError, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
export default function Page() {
const data = useLoaderData();
return (
<div>
Error page
<Suspense fallback={<div>loading ...</div>}>
<Await resolve={data.data} errorElement={<ErrorElement />}>
{(data: any) => {
return <div>never displayed</div>;
}}
</Await>
</Suspense>
</div>
);
}
function ErrorElement() {
const error = useAsyncError() as Error;
return <p>Something went wrong! {error.message}</p>;
}