In this tutorial, we will build a B2B React CRM application with Refine Framework and deploy it to DigitalOcean App Platform.
At the end of this tutorial, we’ll have a CRM application that includes:
While doing these, we’ll use the:
@refinedev/nestjs-query
package as our data provider.You can get the final source code of the application on GitHub.
Refine is a React meta-framework for building data-intensive B2B CRUD web applications like internal tools, dashboards, and admin panels. It ships with various hooks and components to reduce the development time and increase the developer experience.
It is designed to build production-ready enterprise B2B apps. Instead of starting from scratch, it provides essential hooks and components to help with tasks such as data & state management, handling authentication, and managing permissions.
So you can focus on building the important parts of your app without getting bogged down in the technical stuff.
Refine is particularly effective in situations where managing data is key, such as:
Refine’s headless architecture allows the flexibility to use any UI library or custom CSS. Additionally, it has built-in support for popular open-source UI libraries, including Ant Design, Material UI, Mantine, and Chakra UI.
We’ll use the npm create refine-app
command to interactively initialize the project.
npm create refine-app@latest
Select the following options when prompted:
✔ Choose a project template · Vite
✔ What would you like to name your project?: · crm-app
✔ Choose your backend service to connect: · NestJS Query
✔ Do you want to use a UI Framework?: · Ant Design
✔ Do you need any Authentication logic?: · None
✔ Do you need i18n (Internationalization) support?: · No
✔ Choose a package manager: · npm
Once the setup is complete, navigate to the project folder and start your app with:
npm run dev
Open http://localhost:5173
in your browser to see the app.
This page will serve as an introduction to the CRM app, showing various metrics and charts. It will be the first page users see when they access the app.
Initially, we’ll create a <Dashboard />
component in src/pages/dashboard/index.tsx
directory with the following code:
import { Button } from "antd";
export const Dashboard = () => {
return (
<div>
<h1>Dashboard</h1>
<Button type="primary">Primary Button</Button>
</div>
);
};
To render the component we created in the “/” path, let’s add the necessary resources and routes to the <Refine />
component in src/App.tsx
.
<App />
codeimport { Refine } from '@refinedev/core';
import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar';
import { ThemedLayoutV2, useNotificationProvider } from '@refinedev/antd';
import dataProvider, {
GraphQLClient,
liveProvider,
} from '@refinedev/nestjs-query';
import { createClient } from 'graphql-ws';
import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom';
import routerBindings, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from '@refinedev/react-router-v6';
import {
DashboardOutlined,
ShopOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { ColorModeContextProvider } from './contexts/color-mode';
import { Dashboard } from "./pages/dashboard";
import '@refinedev/antd/dist/reset.css';
const API_URL = 'https://api.crm.refine.dev/graphql';
const WS_URL = 'wss://api.crm.refine.dev/graphql';
const ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsImVtYWlsIjoiamltLmhhbHBlcnRAZHVuZGVybWlmZmxpbi5jb20iLCJpYXQiOjE2OTQ2ODI0OTksImV4cCI6MTg1MjQ3MDQ5OX0.4PF7-VYY4tlpuvGHmsunaH_ETLd-N_ANSjEB_NiPExw';
const gqlClient = new GraphQLClient(API_URL, {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
});
const wsClient = createClient({
url: WS_URL,
connectionParams: () => ({
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
}),
});
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider(gqlClient)}
liveProvider={liveProvider(wsClient)}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
resources={[
{
name: 'dashboard',
list: '/',
meta: {
icon: <DashboardOutlined />,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
liveMode: 'auto',
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/">
<Route index element={<Dashboard />} />
</Route>
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;
We’ve included the dashboard resource in the resources
prop of the <Refine />
component. Additionally, we assigned “/” to the list
prop of the dashboard resource, which resulted in the creation of a sidebar menu item.
Additionally, we’ve added the <Dashboard />
component to the “/” path by using the <Route />
component from the react-router-dom
package. We’ve also added the <ThemedLayoutV2 />
component from the @refinedev/antd
package to the <Route />
component to wrap the <Dashboard />
component with the layout component.
Info: You can find more information about resources and adding routes in the React Router v6.
Also, we used the fake CRM GraphQL API to fetch the data, so we updated the values of the following constants API_URL
and WS_URL
to use the CRM GraphQL API
endpoints. Also, this API has some authentication rules, but we won’t implement any authentication logic. We disabled the authentication rules by passing the Authorization
header to the GraphQLClient
constructor.
Now, if you navigate to the “/” path, you should see the <Dashboard />
page.
MetricCard
componentLet’s create a <MetricCard />
component to show the metrics on the dashboard page. We’ll use the Ant Design components to build the metric card and Ant Design Charts to build the chart.
First, we’ll install the specific Antd chart package.
npm install @ant-design/plots@1.2.5
Then, create a src/components/metricCard/index.tsx
file with the following code:
<MetricCard />
componentimport React, { FC, PropsWithChildren } from "react";
import { Card, Skeleton, Typography } from "antd";
import { useList } from "@refinedev/core";
import { Area, AreaConfig } from "@ant-design/plots";
import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons";
type MetricType = "companies" | "contacts" | "deals";
export const MetricCard = ({ variant }: { variant: MetricType }) => {
const { data, isLoading, isError, error } = useList({
resource: variant,
liveMode: "off",
meta: {
fields: ["id"],
},
});
if (isError) {
console.error("Error fetching dashboard data", error);
return null;
}
const { primaryColor, secondaryColor, icon, title } = variants[variant];
const config: AreaConfig = {
style: {
height: "48px",
width: "100%",
},
appendPadding: [1, 0, 0, 0],
padding: 0,
syncViewPadding: true,
data: variants[variant].data,
autoFit: true,
tooltip: false,
animation: false,
xField: "index",
yField: "value",
xAxis: false,
yAxis: {
tickCount: 12,
label: { style: { fill: "transparent" } },
grid: { line: { style: { stroke: "transparent" } } },
},
smooth: true,
areaStyle: () => ({
fill: `l(270) 0:#fff 0.2:${secondaryColor} 1:${primaryColor}`,
}),
line: { color: primaryColor },
};
return (
<Card
bodyStyle={{
padding: "8px 8px 8px 12px",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
}}
size="small"
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
whiteSpace: "nowrap",
}}
>
{icon}
<Typography.Text
className="secondary"
style={{ marginLeft: "8px" }}
>
{title}
</Typography.Text>
</div>
{isLoading ? (
<div
style={{
display: "flex",
alignItems: "center",
height: "60px",
}}
>
<Skeleton.Button
style={{ marginLeft: "48px", marginTop: "8px" }}
/>
</div>
) : (
<Typography.Text
strong
style={{
fontSize: 38,
textAlign: "start",
marginLeft: "48px",
fontVariantNumeric: "tabular-nums",
}}
>
{data?.total}
</Typography.Text>
)}
</div>
<div
style={{
marginTop: "auto",
marginLeft: "auto",
width: "110px",
}}
>
<Area {...config} />
</div>
</Card>
);
};
const IconWrapper: FC<PropsWithChildren<{ color: string }>> = ({
color,
children,
}) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: color,
}}
>
{children}
</div>
);
};
const variants: {
[key in MetricType]: {
primaryColor: string;
secondaryColor?: string;
icon: React.ReactNode;
title: string;
data: { index: string; value: number }[];
};
} = {
companies: {
primaryColor: "#1677FF",
secondaryColor: "#BAE0FF",
icon: (
<IconWrapper color="#E6F4FF">
<ShopOutlined
className="md"
style={{
color: "#1677FF",
}}
/>
</IconWrapper>
),
title: "Number of companies",
data: [
{ index: "1", value: 3500 },
{ index: "2", value: 2750 },
{ index: "3", value: 5000 },
{ index: "4", value: 4250 },
{ index: "5", value: 5000 },
],
},
contacts: {
primaryColor: "#52C41A",
secondaryColor: "#D9F7BE",
icon: (
<IconWrapper color="#F6FFED">
<TeamOutlined
className="md"
style={{
color: "#52C41A",
}}
/>
</IconWrapper>
),
title: "Number of contacts",
data: [
{ index: "1", value: 10000 },
{ index: "2", value: 19500 },
{ index: "3", value: 13000 },
{ index: "4", value: 17000 },
{ index: "5", value: 13000 },
{ index: "6", value: 20000 },
],
},
deals: {
primaryColor: "#FA541C",
secondaryColor: "#FFD8BF",
icon: (
<IconWrapper color="#FFF2E8">
<AuditOutlined
className="md"
style={{
color: "#FA541C",
}}
/>
</IconWrapper>
),
title: "Total deals in pipeline",
data: [
{ index: "1", value: 1000 },
{ index: "2", value: 1300 },
{ index: "3", value: 1200 },
{ index: "4", value: 2000 },
{ index: "5", value: 800 },
{ index: "6", value: 1700 },
{ index: "7", value: 1400 },
{ index: "8", value: 1800 },
],
},
};
In the above code, the component fetches the data from the API and renders the metric card with the data and chart.
For fetching the data, we used the useCustom
hook, and we passed the raw query using the meta.rawQuery
property. When we pass the raw query by meta.rawQuery
prop, @refinedev/nestjs-graphql
data provider will pass it to the GraphQL API as it is. This is useful when you want to use some advanced features of the GraphQL API.
We also used the <Area />
component from the @ant-design/plots
package to render the chart. We passed the config
object to the <Area />
component to configure the chart.
Now, let’s update the <Dashboard />
component to use the <MetricCard />
component we created.
import { Row, Col } from "antd";
import { MetricCard } from "../../components/metricCard";
export const Dashboard = () => {
return (
<Row gutter={[32, 32]}>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="companies" />
</Col>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="contacts" />
</Col>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="deals" />
</Col>
</Row>
);
};
If you navigate to the "/"
path, you should see the updated dashboard page.
DealChart
componentWe’ll add charts to the dashboard to show the deals summary by creating a <DealChart />
component. We’ll use the Ant Design Charts to build the chart and Ant Design components to build the card.
First, install the dayjs
package for managing the dates.
npm install dayjs
Create a src/components/dealChart/index.tsx
file with the following code:
<DealChart />
componentimport React, { useMemo } from "react";
import { useList } from "@refinedev/core";
import { Card, Typography } from "antd";
import { Area, AreaConfig } from "@ant-design/plots";
import { DollarOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
export const DealChart: React.FC<{}> = () => {
const { data } = useList({
resource: "dealStages",
filters: [{ field: "title", operator: "in", value: ["WON", "LOST"] }],
meta: {
fields: [
"title",
{
dealsAggregate: [
{ groupBy: ["closeDateMonth", "closeDateYear"] },
{ sum: ["value"] },
],
},
],
},
});
const dealData = useMemo(() => {
const won = data?.data
.find((node) => node.title === "WON")
?.dealsAggregate.map((item: any) => {
const { closeDateMonth, closeDateYear } = item.groupBy!;
const date = dayjs(`${closeDateYear}-${closeDateMonth}-01`);
return {
timeUnix: date.unix(),
timeText: date.format("MMM YYYY"),
value: item.sum?.value,
state: "Won",
};
});
const lost = data?.data
.find((node) => node.title === "LOST")
?.dealsAggregate.map((item: any) => {
const { closeDateMonth, closeDateYear } = item.groupBy!;
const date = dayjs(`${closeDateYear}-${closeDateMonth}-01`);
return {
timeUnix: date.unix(),
timeText: date.format("MMM YYYY"),
value: item.sum?.value,
state: "Lost",
};
});
return [...(won || []), ...(lost || [])].sort(
(a, b) => a.timeUnix - b.timeUnix,
);
}, [data]);
const config: AreaConfig = {
isStack: false,
data: dealData,
xField: "timeText",
yField: "value",
seriesField: "state",
animation: true,
startOnZero: false,
smooth: true,
legend: { offsetY: -6 },
yAxis: {
tickCount: 4,
label: {
formatter: (v) => `$${Number(v) / 1000}k`,
},
},
tooltip: {
formatter: (data) => ({
name: data.state,
value: `$${Number(data.value) / 1000}k`,
}),
},
areaStyle: (datum) => {
const won = "l(270) 0:#ffffff 0.5:#b7eb8f 1:#52c41a";
const lost = "l(270) 0:#ffffff 0.5:#f3b7c2 1:#ff4d4f";
return { fill: datum.state === "Won" ? won : lost };
},
color: (datum) => (datum.state === "Won" ? "#52C41A" : "#F5222D"),
};
return (
<Card
style={{ height: "432px" }}
headStyle={{ padding: "8px 16px" }}
bodyStyle={{ padding: "24px 24px 0px 24px" }}
title={
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<DollarOutlined />
<Typography.Text style={{ marginLeft: ".5rem" }}>
Deals
</Typography.Text>
</div>
}
>
<Area {...config} height={325} />
</Card>
);
};
In the above code, similar to the <MetricCard />
component, we used the useCustom
hook to fetch the data from the GraphQL API and render the chart with the data.
After fetching the data, we grouped the data by the date fields and “LOST” and “WON” deal stages. Then, we passed the grouped data to the data
property of the <Area />
component.
Now, let’s update the <Dashboard />
component to use the <DealChart />
component we created.
import { Row, Col } from "antd";
import { MetricCard } from "../../components/metricCard";
import { DealChart } from "../../components/dealChart";
export const Dashboard = () => {
return (
<Row gutter={[32, 32]}>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="companies" />
</Col>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="contacts" />
</Col>
<Col xs={24} sm={24} xl={8}>
<MetricCard variant="deals" />
</Col>
<Col span={24}>
<DealChart />
</Col>
</Row>
);
};
The charts will look like below.
In this phase, we’re going to develop the ‘list,’ ‘create,’ ‘edit,’ and ‘show’ pages for companies. However, before we start working on these pages, we should first update the <Refine />
component to include the ‘companies’ resource.
<App />
codeimport { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { ThemedLayoutV2, useNotificationProvider } from "@refinedev/antd";
import dataProvider, {
GraphQLClient,
liveProvider,
} from "@refinedev/nestjs-query";
import { createClient } from "graphql-ws";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import routerBindings, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import { DashboardOutlined, ShopOutlined} from "@ant-design/icons";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { Dashboard } from "./pages/dashboard";
import "@refinedev/antd/dist/reset.css";
const API_URL = "https://api.crm.refine.dev/graphql";
const WS_URL = "wss://api.crm.refine.dev/graphql";
const ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsImVtYWlsIjoiamltLmhhbHBlcnRAZHVuZGVybWlmZmxpbi5jb20iLCJpYXQiOjE2OTQ2ODI0OTksImV4cCI6MTg1MjQ3MDQ5OX0.4PF7-VYY4tlpuvGHmsunaH_ETLd-N_ANSjEB_NiPExw";
const gqlClient = new GraphQLClient(API_URL, {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
});
const wsClient = createClient({
url: WS_URL,
connectionParams: () => ({
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
}),
});
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider(gqlClient)}
liveProvider={liveProvider(wsClient)}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
resources={[
{
name: "dashboard",
list: "/",
meta: {
icon: <DashboardOutlined />,
},
},
{
name: "companies",
list: "/companies",
create: "/companies/create",
edit: "/companies/edit/:id",
show: "/companies/show/:id",
meta: {
canDelete: true,
icon: <ShopOutlined />,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
liveMode: "auto",
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/">
<Route index element={<Dashboard />} />
</Route>
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;
The resource definition mentioned doesn’t actually create any CRUD pages itself. Instead, it establishes the routes that these CRUD pages will follow. These routes are essential for ensuring the proper functionality of various Refine hooks and components.
For instance, the useNavigation
hook relies on these routes (list
, create
, edit
, and show
) to help users navigate between different pages in your application. Additionally, certain data hooks, like useTable
, will automatically use the resource name if you don’t explicitly provide the resource prop.
The List page will display company data in a table. To fetch the data, we’ll use the useTable
hook from @refinedev/antd
package, and to render the table, we’ll use the <Table />
component from the antd
.
Let’s create a src/pages/companies/list.tsx
file with the following code:
<CompanyList />
componentimport React from "react";
import { IResourceComponentsProps, BaseRecord } from "@refinedev/core";
import {
useTable,
List,
EditButton,
ShowButton,
DeleteButton,
UrlField,
TextField,
} from "@refinedev/antd";
import { Table, Space, Avatar, Input, Form } from "antd";
export const CompanyList: React.FC<IResourceComponentsProps> = () => {
const { tableProps, searchFormProps } = useTable({
meta: {
fields: [
"id",
"avatarUrl",
"name",
"businessType",
"companySize",
"country",
"website",
{ salesOwner: ["id", "name"] },
],
},
onSearch: (params: { name: string }) => [
{
field: "name",
operator: "contains",
value: params.name,
},
],
});
return (
<List
headerButtons={({ defaultButtons }) => (
<>
<Form
{...searchFormProps}
onValuesChange={() => {
searchFormProps.form?.submit();
}}
>
<Form.Item noStyle name="name">
<Input.Search placeholder="Search by name" />
</Form.Item>
</Form>
{defaultButtons}
</>
)}
>
<Table {...tableProps} rowKey="id">
<Table.Column
title="Name"
render={(
_,
record: { name: string; avatarUrl: string },
) => (
<Space>
<Avatar
src={record.avatarUrl}
size="large"
shape="square"
alt={record.name}
/>
<TextField value={record.name} />
</Space>
)}
/>
<Table.Column dataIndex="businessType" title="Type" />
<Table.Column dataIndex="companySize" title="Size" />
<Table.Column dataIndex="country" title="Country" />
<Table.Column
dataIndex={["website"]}
title="Website"
render={(value: string) => <UrlField value={value} />}
/>
<Table.Column
dataIndex={["salesOwner", "name"]}
title="Sales Owner"
/>
<Table.Column
title="Actions"
dataIndex="actions"
render={(_, record: BaseRecord) => (
<Space>
<EditButton
hideText
size="small"
recordItemId={record.id}
/>
<ShowButton
hideText
size="small"
recordItemId={record.id}
/>
<DeleteButton
hideText
size="small"
recordItemId={record.id}
/>
</Space>
)}
/>
</Table>
</List>
);
};
We fetched data using the useTable
hook and specified the fields to retrieve by setting them in the meta.fields
property. This data was then displayed in a table format using the <Table />
component.
For a better understanding of how GraphQL queries are formulated, you can refer to the GraphQL guide in the documentation.
Our table includes various columns like company name, business type, size, country, website, and sales owner. We followed the guidelines from the Ant Design Table for setting up these columns and used specific components like <TextField />
and <UrlField />
from @refinedev/antd
and <Avatar />
from antd
for customization.
We added functionality with <EditButton />
, <ShowButton />
, and <DeleteButton />
components for different actions on the records.
For filtering, we utilized the useTable
features, implementing a search form with the <Form />
component. User searches trigger through the onSearch
prop of useTable
.
To compile the company CRUD pages, we need to create an index.ts
file in the src/pages/companies
directory, following the provided code.
export * from "./list";
Next, import the <CompanyList />
component in src/App.tsx
and add a route for rendering it.
<App />
codeimport { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { ThemedLayoutV2, useNotificationProvider } from "@refinedev/antd";
import dataProvider, {
GraphQLClient,
liveProvider,
} from "@refinedev/nestjs-query";
import { createClient } from "graphql-ws";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import routerBindings, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import { DashboardOutlined, ShopOutlined } from "@ant-design/icons";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { Dashboard } from "./pages/dashboard";
import { CompanyList } from "./pages/companies";
import "@refinedev/antd/dist/reset.css";
const API_URL = 'https://api.crm.refine.dev/graphql';
const WS_URL = 'wss://api.crm.refine.dev/graphql';
const ACCESS_TOKEN =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsImVtYWlsIjoiamltLmhhbHBlcnRAZHVuZGVybWlmZmxpbi5jb20iLCJpYXQiOjE2OTQ2ODI0OTksImV4cCI6MTg1MjQ3MDQ5OX0.4PF7-VYY4tlpuvGHmsunaH_ETLd-N_ANSjEB_NiPExw';
const gqlClient = new GraphQLClient(API_URL, {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
});
const wsClient = createClient({
url: WS_URL,
connectionParams: () => ({
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
}),
});
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider(gqlClient)}
liveProvider={liveProvider(wsClient)}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
resources={[
{
name: "dashboard",
list: "/",
meta: {
icon: <DashboardOutlined />,
},
},
{
name: "companies",
list: "/companies",
create: "/companies/create",
edit: "/companies/edit/:id",
show: "/companies/show/:id",
meta: {
canDelete: true,
icon: <ShopOutlined />,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
liveMode: "auto",
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/">
<Route index element={<Dashboard />} />
</Route>
<Route path="/companies">
<Route index element={<CompanyList />} />
</Route>
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;
Now, if you navigate to the “/companies” path, you should see the list page.
The Create page will show a form to create a new company record.
We will use the <Form />
component, and for managing form submissions, the useForm
hook will be utilized.
Let’s create a src/pages/companies/create.tsx
file with the following code:
<CompanyCreate />
componentimport React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, Select } from "antd";
export const CompanyCreate: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps } = useForm();
const { selectProps } = useSelect({
resource: "users",
meta: {
fields: ["name", "id"],
},
optionLabel: "name",
});
return (
<Create saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
name={["name"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Sales Owner"
name="salesOwnerId"
rules={[{ required: true }]}
>
<Select {...selectProps} />
</Form.Item>
<Form.Item label="Business Type" name={["businessType"]}>
<Select
options={[
{ label: "B2B", value: "B2B" },
{ label: "B2C", value: "B2C" },
{ label: "B2G", value: "B2G" },
]}
/>
</Form.Item>
<Form.Item label="Company Size" name={["companySize"]}>
<Select
options={[
{ label: "Enterprise", value: "ENTERPRISE" },
{ label: "Large", value: "LARGE" },
{ label: "Medium", value: "MEDIUM" },
{ label: "Small", value: "SMALL" },
]}
/>
</Form.Item>
<Form.Item label="Country" name={["country"]}>
<Input />
</Form.Item>
<Form.Item label="Website" name={["website"]}>
<Input />
</Form.Item>
</Form>
</Create>
);
};
We used the useSelect
hook to fetch relationship data. We passed the selectProps
to the <Select />
component to render the options.
You might have noticed that we didn’t use the meta.fields
prop in the useForm
hook. This is because we don’t need to retrieve data of the record that was just created after submitting the form. However, if you require this data, you can include the meta.fields
prop.
To export the component, let’s update the src/pages/company/index.ts
file with the following code:
export * from "./list";
export * from "./create";
Next, import the <CompanyCreate />
component in src/App.tsx
and add a route for rendering it.
<App />
codeThe highlighted code shows the new code. You can copy and paste the highlighted code to your src/App.tsx
file.
//...
import { CompanyList, CompanyCreate } from "./pages/companies";
function App() {
return (
//...
<Refine
//...
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
//...
<Route path="/companies">
<Route index element={<CompanyList />} />
<Route path="create" element={<CompanyCreate />} />
</Route>
</Route>
</Routes>
// ...
</Refine>
//...
);
}
export default App;
Now, if you navigate to the “/companies/create” path, you should see the create page.
The edit page will show a form to edit an existing company record. The form will be the same as the create page. However, it will be filled with the existing record data.
Let’s create a src/pages/companies/edit.tsx
file with the following code:
<CompanyEdit />
componentimport React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, Select } from "antd";
export const CompanyEdit: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps } = useForm({
meta: {
fields: [
"id",
"name",
"businessType",
"companySize",
"country",
"website",
],
},
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
name={["name"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="Business Type" name={["businessType"]}>
<Select
options={[
{ label: "B2B", value: "B2B" },
{ label: "B2C", value: "B2C" },
{ label: "B2G", value: "B2G" },
]}
/>
</Form.Item>
<Form.Item label="Company Size" name={["companySize"]}>
<Select
options={[
{ label: "Enterprise", value: "ENTERPRISE" },
{ label: "Large", value: "LARGE" },
{ label: "Medium", value: "MEDIUM" },
{ label: "Small", value: "SMALL" },
]}
/>
</Form.Item>
<Form.Item label="Country" name={["country"]}>
<Input />
</Form.Item>
<Form.Item label="Website" name={["website"]}>
<Input />
</Form.Item>
</Form>
</Edit>
);
};
We used the useForm
hook to handle the form submission. We passed the formProps
and saveButtonProps
to the <Form />
component and <Edit />
component respectively.
We specified the fields we wanted to fill the form with by passing them to the meta.fields
property.
To render the form fields, we used input components from the antd
library.
To export the component, let’s update the src/pages/company/index.ts
file with the following code:
export * from "./list";
export * from "./create";
export * from "./edit";
Next, import the <CompanyEdit />
component in src/App.tsx
and add a route for rendering it.
<App />
codeThe highlighted code shows the new code. You can copy and paste the highlighted code to your src/App.tsx
file.
//...
import { CompanyList, CompanyCreate, CompanyEdit } from "./pages/companies";
function App() {
return (
//...
<Refine
//...
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
//...
<Route path="/companies">
<Route index element={<CompanyList />} />
<Route path="create" element={<CompanyCreate />} />
<Route path="edit/:id" element={<CompanyEdit />} />
</Route>
</Route>
</Routes>
// ...
</Refine>
);
}
export default App;
Now, if you navigate to the “/companies/:id/edit” path, you should see the edit page.
The show page will show the details of an existing company record.
Let’s create a src/pages/companies/show.tsx
file with the following code:
<CompanyShow />
componentimport React from "react";
import { IResourceComponentsProps, useShow } from "@refinedev/core";
import { Show, NumberField, TextField } from "@refinedev/antd";
import { Typography } from "antd";
const { Title } = Typography;
export const CompanyShow: React.FC<IResourceComponentsProps> = () => {
const { queryResult } = useShow({
meta: {
fields: [
"id",
"name",
"businessType",
"companySize",
"country",
"website",
],
},
});
const { data, isLoading } = queryResult;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Title level={5}>Id</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>Name</Title>
<TextField value={record?.name} />
<Title level={5}>Business Type</Title>
<TextField value={record?.businessType} />
<Title level={5}>Company Size</Title>
<TextField value={record?.companySize} />
<Title level={5}>Country</Title>
<TextField value={record?.country} />
<Title level={5}>Website</Title>
<TextField value={record?.website} />
</Show>
);
};
To fetch the data, we’ll use the useShow
hook. Again, we specified the fields we wanted to fetch by passing them to the meta.fields
property. We then passed the resulting queryResult.isLoading
to the <Show />
component to show the loading indicator while fetching the data.
To render the record data, we used the <NumberField />
and <TextField />
components from the @refinedev/antd
package.
To export the component, let’s update the src/pages/company/index.ts
file with the following code:
export * from "./list";
export * from "./create";
export * from "./edit";
export * from "./show";
Next, import the <CompanyShow />
component in src/App.tsx
and add a route for rendering it.
<App />
codeThe highlighted code shows the new code. You can copy and paste the highlighted code to your src/App.tsx
file.
//...
import {
CompanyList,
CompanyCreate,
CompanyEdit,
CompanyShow,
} from "./pages/companies";
function App() {
return (
//...
<Refine
//...
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
//...
<Route path="/companies">
<Route index element={<CompanyList />} />
<Route path="create" element={<CompanyCreate />} />
<Route path="edit/:id" element={<CompanyEdit />} />
<Route path="show/:id" element={<CompanyShow />} />
</Route>
</Route>
</Routes>
// ...
</Refine>
);
}
export default App;
Now, if you navigate to the “/companies/show/:id” path, you should see the show page.
In the previous step, we built the company CRUD pages. In this step, we’ll build the contact CRUD pages similarly. So, we won’t repeat the explanations we made in the previous step. If you need more information about the steps, you can refer to the previous step.
Let’s start by defining the contact resource in src/App.tsx
file as follows:
<App />
codeimport { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { ThemedLayoutV2, useNotificationProvider } from "@refinedev/antd";
import dataProvider, {
GraphQLClient,
liveProvider,
} from "@refinedev/nestjs-query";
import { createClient } from "graphql-ws";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import routerBindings, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import {
DashboardOutlined,
ShopOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { Dashboard } from "./pages/dashboard";
import {
CompanyList,
CompanyCreate,
CompanyEdit,
CompanyShow,
} from "./pages/companies";
import "@refinedev/antd/dist/reset.css";
const API_URL = 'https://api.crm.refine.dev/graphql';
const WS_URL = 'wss://api.crm.refine.dev/graphql';
const ACCESS_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsImVtYWlsIjoiamltLmhhbHBlcnRAZHVuZGVybWlmZmxpbi5jb20iLCJpYXQiOjE2OTQ2ODI0OTksImV4cCI6MTg1MjQ3MDQ5OX0.4PF7-VYY4tlpuvGHmsunaH_ETLd-N_ANSjEB_NiPExw';
const gqlClient = new GraphQLClient(API_URL, {
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
});
const wsClient = createClient({
url: WS_URL,
connectionParams: () => ({
headers: {
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
}),
});
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider(gqlClient)}
liveProvider={liveProvider(wsClient)}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
resources={[
{
name: "dashboard",
list: "/",
meta: {
icon: <DashboardOutlined />,
},
},
{
name: "companies",
list: "/companies",
create: "/companies/create",
edit: "/companies/edit/:id",
show: "/companies/show/:id",
meta: {
canDelete: true,
icon: <ShopOutlined />,
},
},
{
name: "contacts",
list: "/contacts",
create: "/contacts/create",
edit: "/contacts/edit/:id",
show: "/contacts/show/:id",
meta: {
canDelete: true,
icon: <TeamOutlined />,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
liveMode: "auto",
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/">
<Route index element={<Dashboard />} />
</Route>
<Route path="/companies">
<Route index element={<CompanyList />} />
<Route
path="create"
element={<CompanyCreate />}
/>
<Route
path="edit/:id"
element={<CompanyEdit />}
/>
<Route
path="show/:id"
element={<CompanyShow />}
/>
</Route>
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;
Let’s create CRUD pages for the contact resource as follows:
Create src/pages/contacts/list.tsx
file with the following code:
<ContactList />
componentimport React from "react";
import { IResourceComponentsProps, BaseRecord } from "@refinedev/core";
import {
useTable,
List,
EditButton,
ShowButton,
DeleteButton,
EmailField,
TextField,
} from "@refinedev/antd";
import { Table, Space, Avatar, Form, Input } from "antd";
export const ContactList: React.FC<IResourceComponentsProps> = () => {
const { tableProps, searchFormProps } = useTable({
meta: {
fields: [
"avatarUrl",
"id",
"name",
"email",
{ company: ["id", "name"] },
"jobTitle",
"phone",
"status",
],
},
onSearch: (params: { name: string }) => [
{
field: "name",
operator: "contains",
value: params.name,
},
],
});
return (
<List
headerButtons={({ defaultButtons }) => (
<>
<Form
{...searchFormProps}
onValuesChange={() => {
searchFormProps.form?.submit();
}}
>
<Form.Item noStyle name="name">
<Input.Search placeholder="Search by name" />
</Form.Item>
</Form>
{defaultButtons}
</>
)}
>
<Table {...tableProps} rowKey="id">
<Table.Column
title="Name"
width={200}
render={(
_,
record: { name: string; avatarUrl: string },
) => (
<Space>
<Avatar src={record.avatarUrl} alt={record.name} />
<TextField value={record.name} />
</Space>
)}
/>
<Table.Column dataIndex={["company", "name"]} title="Company" />
<Table.Column dataIndex="jobTitle" title="Job Title" />
<Table.Column
dataIndex={["email"]}
title="Email"
render={(value) => <EmailField value={value} />}
/>
<Table.Column dataIndex="phone" title="Phone" />
<Table.Column dataIndex="status" title="Status" />
<Table.Column
title="Actions"
dataIndex="actions"
render={(_, record: BaseRecord) => (
<Space>
<EditButton
hideText
size="small"
recordItemId={record.id}
/>
<ShowButton
hideText
size="small"
recordItemId={record.id}
/>
<DeleteButton
hideText
size="small"
recordItemId={record.id}
/>
</Space>
)}
/>
</Table>
</List>
);
};
Create src/pages/contacts/create.tsx
file with the following code:
<ContactCreate />
componentimport React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Create, useForm, useSelect } from "@refinedev/antd";
import { Form, Input, Select } from "antd";
export const ContactCreate: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps } = useForm();
const { selectProps: companySelectProps } = useSelect({
resource: "companies",
optionLabel: "name",
meta: {
fields: ["id", "name"],
},
});
const { selectProps: salesOwnerSelectProps } = useSelect({
resource: "users",
meta: {
fields: ["name", "id"],
},
optionLabel: "name",
});
return (
<Create saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
name={["name"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Email"
name={["email"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Company"
name={["companyId"]}
rules={[{ required: true }]}
>
<Select {...companySelectProps} />
</Form.Item>
<Form.Item
label="Sales Owner"
name="salesOwnerId"
rules={[{ required: true }]}
>
<Select {...salesOwnerSelectProps} />
</Form.Item>
<Form.Item label="Job Title" name={["jobTitle"]}>
<Input />
</Form.Item>
<Form.Item label="Phone" name={["phone"]}>
<Input />
</Form.Item>
<Form.Item label="Status" name={["status"]}>
<Select
options={[
{ label: "NEW", value: "NEW" },
{ label: "CONTACTED", value: "CONTACTED" },
{ label: "INTERESTED", value: "INTERESTED" },
{ label: "UNQUALIFIED", value: "UNQUALIFIED" },
{ label: "QUALIFIED", value: "QUALIFIED" },
{ label: "NEGOTIATION", value: "NEGOTIATION" },
{ label: "LOST", value: "LOST" },
{ label: "WON", value: "WON" },
{ label: "CHURNED", value: "CHURNED" },
]}
/>
</Form.Item>
</Form>
</Create>
);
};
Create src/pages/contacts/edit.tsx
file with the following code:
<ContactEdit />
componentimport React from "react";
import { IResourceComponentsProps } from "@refinedev/core";
import { Edit, useForm } from "@refinedev/antd";
import { Form, Input, Select } from "antd";
export const ContactEdit: React.FC<IResourceComponentsProps> = () => {
const { formProps, saveButtonProps } = useForm({
meta: {
fields: [
"avatarUrl",
"id",
"name",
"email",
"jobTitle",
"phone",
"status",
],
},
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Form {...formProps} layout="vertical">
<Form.Item
label="Name"
name={["name"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Email"
name={["email"]}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item label="Job Title" name={["jobTitle"]}>
<Input />
</Form.Item>
<Form.Item label="Phone" name={["phone"]}>
<Input />
</Form.Item>
<Form.Item label="Status" name={["status"]}>
<Select
options={[
{ label: "NEW", value: "NEW" },
{ label: "CONTACTED", value: "CONTACTED" },
{ label: "INTERESTED", value: "INTERESTED" },
{ label: "UNQUALIFIED", value: "UNQUALIFIED" },
{ label: "QUALIFIED", value: "QUALIFIED" },
{ label: "NEGOTIATION", value: "NEGOTIATION" },
{ label: "LOST", value: "LOST" },
{ label: "WON", value: "WON" },
{ label: "CHURNED", value: "CHURNED" },
]}
/>
</Form.Item>
</Form>
</Edit>
);
};
Create src/pages/contacts/show.tsx
file with the following code:
<ContactShow />
componentimport React from "react";
import { IResourceComponentsProps, useShow } from "@refinedev/core";
import { Show, NumberField, TextField, EmailField } from "@refinedev/antd";
import { Typography } from "antd";
const { Title } = Typography;
export const ContactShow: React.FC<IResourceComponentsProps> = () => {
const { queryResult } = useShow({
meta: {
fields: [
"id",
"name",
"email",
{ company: ["id", "name"] },
"jobTitle",
"phone",
"status",
],
},
});
const { data, isLoading } = queryResult;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Title level={5}>Id</Title>
<NumberField value={record?.id ?? ""} />
<Title level={5}>Name</Title>
<TextField value={record?.name} />
<Title level={5}>Email</Title>
<EmailField value={record?.email} />
<Title level={5}>Company</Title>
<TextField value={record?.company?.name} />
<Title level={5}>Job Title</Title>
<TextField value={record?.jobTitle} />
<Title level={5}>Phone</Title>
<TextField value={record?.phone} />
<Title level={5}>Status</Title>
<TextField value={record?.status} />
</Show>
);
};
After creating the CRUD pages, let’s create a src/pages/contacts/index.ts
file to export the pages as follows:
export * from "./list";
export * from "./create";
export * from "./edit";
export * from "./show";
To render the contact CRUD pages, let’s update the src/App.tsx
file with the following code:
<App />
codeimport { Refine } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { ThemedLayoutV2, notificationProvider } from "@refinedev/antd";
import dataProvider, {
GraphQLClient,
liveProvider,
} from "@refinedev/nestjs-query";
import { createClient } from "graphql-ws";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import routerBindings, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import {
DashboardOutlined,
ShopOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { Dashboard } from "./pages/dashboard";
import {
CompanyList,
CompanyCreate,
CompanyEdit,
CompanyShow,
} from "./pages/companies";
import {
ContactList,
ContactCreate,
ContactEdit,
ContactShow,
} from "./pages/contacts";
import "@refinedev/antd/dist/reset.css";
const API_URL = "https://api.crm.refine.dev/graphql";
const WS_URL = "wss://api.crm.refine.dev/graphql";
const gqlClient = new GraphQLClient(API_URL, {
headers: {
Authorization: `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjMsImVtYWlsIjoiamltLmhhbHBlcnRAZHVuZGVybWlmZmxpbi5jb20iLCJpYXQiOjE2OTQ2ODI0OTksImV4cCI6MTg1MjQ3MDQ5OX0.4PF7-VYY4tlpuvGHmsunaH_ETLd-N_ANSjEB_NiPExw`,
},
});
const wsClient = createClient({ url: WS_URL });
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<Refine
dataProvider={dataProvider(gqlClient)}
liveProvider={liveProvider(wsClient)}
notificationProvider={notificationProvider}
routerProvider={routerBindings}
resources={[
{
name: "dashboard",
list: "/",
meta: {
icon: <DashboardOutlined />,
},
},
{
name: "companies",
list: "/companies",
create: "/companies/create",
edit: "/companies/edit/:id",
show: "/companies/show/:id",
meta: {
canDelete: true,
icon: <ShopOutlined />,
},
},
{
name: "contacts",
list: "/contacts",
create: "/contacts/create",
edit: "/contacts/edit/:id",
show: "/contacts/show/:id",
meta: {
canDelete: true,
icon: <TeamOutlined />,
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
liveMode: "auto",
}}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route path="/">
<Route index element={<Dashboard />} />
</Route>
<Route path="/companies">
<Route index element={<CompanyList />} />
<Route
path="create"
element={<CompanyCreate />}
/>
<Route
path="edit/:id"
element={<CompanyEdit />}
/>
<Route
path="show/:id"
element={<CompanyShow />}
/>
</Route>
<Route path="/contacts">
<Route index element={<ContactList />} />
<Route
path="create"
element={<ContactCreate />}
/>
<Route
path="edit/:id"
element={<ContactEdit />}
/>
<Route
path="show/:id"
element={<ContactShow />}
/>
</Route>
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</ColorModeContextProvider>
</RefineKbarProvider>
</BrowserRouter>
);
}
export default App;
After these changes, you should be able to navigate to the contact CRUD pages as below:
In this step, we’ll deploy the application to the DigitalOcean App Platform. To do that, we’ll host the source code on GitHub and connect the GitHub repository to the App Platform.
Log in to your GitHub account and create a new repository named crm-app
. You can make the repository public or private:
After creating the repository, navigate to the project directory and run the following command to initialize a new Git repository:
git init
Next, add all the files to the Git repository with this command:
git add .
Then, commit the files with this command:
git commit -m "Initial commit"
Next, add the GitHub repository as a remote repository with this command:
git remote add origin <your-github-repository-url>
Next, specify that you want to push your code to the main
branch with this command:
git branch -M main
Finally, push the code to the GitHub repository with this command:
git push -u origin main
When prompted, enter your GitHub credentials to push your code.
You’ll receive a success message after the code is pushed to the GitHub repository.
In this section, you pushed your project to GitHub so that you can access it using DigitalOcean Apps. The next step is to create a new DigitalOcean App using your project and set up automatic deployment.
In this step, you’ll take your React application and set it up on the DigitalOcean App Platform. You’ll link your GitHub repository to DigitalOcean, set up the building process, and create your initial project deployment. Once your project is live, any future changes you make will automatically trigger a new build and update.
By the end of this step, you’ll have successfully deployed your application on DigitalOcean with continuous delivery capabilities.
Log in to your DigitalOcean account and navigate to the Apps page. Click the Create App button:
If you haven’t connected your GitHub account to DigitalOcean, you’ll be prompted to do so. Click the Connect to GitHub button. A new window will open, asking you to authorize DigitalOcean to access your GitHub account.
After you authorize DigitalOcean, you’ll be redirected back to the DigitalOcean Apps page. The next step is to select your GitHub repository. After you select your repository, you’ll be prompted to select a branch to deploy. Select the main
branch and click the Next button.
After that, you’ll see the configuration steps for your application. In this tutorial, you can click the Next button to skip the configuration steps. However, you can also configure your application as you wish.
Wait for the build to complete. After the build is complete, press Live App to access your project in the browser. It will be the same as the project you tested locally, but this will be live on the web with a secure URL. Also, you can follow this tutorial available on the DigitalOcean community site to learn how to deploy react-based applications to App Platform.
In this tutorial, we built a React CRM application using Refine from scratch and got familiar with how to build a fully functional CRUD app.
Also, we’ll demonstrate how to deploy your application to the DigitalOcean App Platform.
If you want to learn more about Refine, you can check out the documentation, and if you have any questions or feedback, you can join the Refine Discord Server.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!