Nested list layout
Create a publish extension with the "Nested list" layout.
A publish extension adds a publish destination to Canva. Users can then publish their design to this destination via Canva's Publish menu.
This tutorial explains how to create a publish extension that uses the Nested list layout. This layout is intended for platforms that:
    Let users users to organize content into folders.
    Supported nested folders.
To learn more about the available layouts for publish extensions, refer to Choose a layout for a publish extension.

Step 1: Set up a Base URL

In the Developer Portal, publish extensions have a Base URL field. This indicates that the extension must provide the URL of a server that can receive and respond to HTTP requests from Canva.
To set up a Base URL, refer to either of the following guides:
You can use any programming language, framework, or architecture to handle requests from Canva, but the examples in this documentation use Express.js.

Step 2: Set up the required endpoints

A publish extension that uses the Nested list layout must support the following endpoints:
The following snippet demonstrates how to set up these endpoints with Express.js:
1
const express = require("express");
2
const fs = require("fs-extra");
3
const jimp = require("jimp");
4
const path = require("path");
5
const url = require("url");
6
7
const app = express();
8
9
app.use(express.json());
10
app.use(express.static("public"));
11
12
app.post("/publish/resources/find", async (request, response) => {
13
// code goes here
14
});
15
16
app.post("/publish/resources/get", async (request, response) => {
17
// code goes here
18
});
19
20
app.post("/publish/resources/upload", async (request, response) => {
21
// code goes here
22
});
23
24
app.listen(process.env.PORT || 3000);
Copied!
This snippet also assumes the following Node.js dependencies are installed:

Step 3: Create an app via the Developer Portal

    1.
    Log in to the Developer Portal.
    2.
    Navigate to the Your integrations page.
    3.
    Click Create an app.
    4.
    In the App name field, enter a name for the app.
    5.
    Agree to the Canva Developer Terms.
    6.
    Click Create app.

Step 4: Add a publish extension to the app

    1.
    Select Publish.
    2.
    In the Base URL field, enter the URL of the server.
    3.
    For the Layout option, select Nested list.
    4.
    For the Output file types option, select the types of files a user can publish to the destination platform. The example in this tutorial requires the file types to be JPG and PNG.
    5.
    For the Max number of pages field, enter the maximum number of pages that a user can publish of their design. The example in this tutorial requires the number of pages to be 1.
Any changes to the form save automatically.

Step 5: Provide a list of files and folders

When a user opens a publish extension that uses the Nested list layout, Canva sends a request to the following endpoint:
1
<base_url>/publish/resources/find
Copied!
The endpoint must respond with an array of resources.
Each resource must have a type property of "CONTAINER" or "IMAGE". Canva renders "CONTAINER" resources as folders and "IMAGE" resources as files. Users can select folders but can't interact with files.
The following snippet demonstrates how to use the contents of the public directory create a list of resources:
1
app.post("/publish/resources/find", async (request, response) => {
2
const dirPath = path.join(__dirname, "public");
3
4
await fs.ensureDir(dirPath);
5
6
const entries = await fs.readdir(dirPath, {
7
withFileTypes: true,
8
});
9
10
const folders = entries
11
.filter((dirent) => dirent.isDirectory())
12
.map((dirent) => {
13
return {
14
type: "CONTAINER",
15
id: path.join(dirPath, dirent.name),
16
name: dirent.name,
17
isOwner: true,
18
readOnly: false,
19
};
20
});
21
22
const files = entries
23
.filter((dirent) => !dirent.isDirectory())
24
.map((dirent) => {
25
return {
26
type: "IMAGE",
27
id: path.join(dirPath, dirent.name),
28
name: dirent.name,
29
isOwner: true,
30
readOnly: false,
31
};
32
});
33
34
response.send({
35
type: "SUCCESS",
36
resources: [...folders, ...files],
37
});
38
});
Copied!
In this case, the id of the resource is the path of the file or directory. This lets you access the path of a selected folder from the extension's other endpoints.
When a resource's isOwner property is false or readOnly property is true, the resource is greyed out and the user can't interact with it (even if it's a folder).
When a response contains files and folders, the folders must appear before the files.

Step 6: Detect when a user opens a folder

If a user selects a folder, Canva sends another request to the following endpoint:
1
<base_url>/publish/resources/find
Copied!
The body of this request includes a containerId property that contains the id of the folder. The extension must respond with resources that belong to the folder.
In this case, the value of the containerId property is a directory path, so the only change you need to make is to set the dirPath variable to the value of the containerId:
1
app.post("/publish/resources/find", async (request, response) => {
2
const dirPath = request.body.containerId || path.join(__dirname, "public");
3
4
await fs.ensureDir(dirPath);
5
6
const entries = await fs.readdir(dirPath, {
7
withFileTypes: true,
8
});
9
10
const folders = entries
11
.filter((dirent) => dirent.isDirectory())
12
.map((dirent) => {
13
return {
14
type: "CONTAINER",
15
id: path.join(dirPath, dirent.name),
16
name: dirent.name,
17
isOwner: true,
18
readOnly: false,
19
};
20
});
21
22
const files = entries
23
.filter((dirent) => !dirent.isDirectory())
24
.map((dirent) => {
25
return {
26
type: "IMAGE",
27
id: path.join(dirPath, dirent.name),
28
name: dirent.name,
29
isOwner: true,
30
readOnly: false,
31
};
32
});
33
34
response.send({
35
type: "SUCCESS",
36
resources: [...folders, ...files],
37
});
38
});
Copied!
But the containerId property isn't defined in the initial request, so it's important to provide the public directory as a fallback.
Based on these changes, opening the extension via the Publish menu displays the contents of the public directory.

Step 7: Verify that the selected folder still exists

When a user is ready to publish their design, they can click the Choose button.
After clicking the Choose button, a Save button appears. When the user clicks this button, Canva sends a request to the the following endpoint:
1
<base_url>/publish/resources/get
Copied!
The body of this request includes an id property that contains the ID of the selected folder.
The endpoint must verify that:
    The folder still exists on the destination platform.
    The user still has access to the folder on the destination platform.
To do this, the endpoint must respond with the details of the selected folder.
The following snippet demonstrates how to verify the existence of a folder in the public directory (and how to respond with an error if the folder doesn't exist):
1
app.post("/publish/resources/get", async (request, response) => {
2
const dirPathExists = await fs.pathExists(request.body.id);
3
4
if (!dirPathExists) {
5
response.send({
6
type: "ERROR",
7
errorCode: "NOT_FOUND",
8
});
9
return;
10
}
11
12
response.send({
13
type: "SUCCESS",
14
resource: {
15
type: "CONTAINER",
16
id: request.body.id,
17
name: path.basename(request.body.id),
18
isOwner: true,
19
readOnly: false,
20
},
21
});
22
});
Copied!
In this case, the id property in the request body is the path of the selected folder.
If the user doesn't select a folder before clicking the Choose button, Canva doesn't send this request.

Step 8: Upload the user's design

When the extension verifies that the selected folder exists, Canva sends a request to the following endpoint:
1
<base_url>/publish/resources/upload
Copied!
The request body contains an array of assets. Each asset has a url, which the extension must use to download the assets to the destination platform.
If the user selects a folder before publishing their design, the request body also includes a parent property that contains the id of the selected folder. The extension must use the parent property to publish the design to the correct location.
The following snippet demonstrates how to download assets to the requested folder:
1
app.post("/publish/resources/upload", async (request, response) => {
2
// Get the first asset from the "assets" array
3
const [asset] = request.body.assets;
4
5
// Download the asset
6
const image = await jimp.read(asset.url);
7
const filePath = path.join(request.body.parent, asset.name);
8
await image.writeAsync(filePath);
9
10
// Respond with the URL of the published design
11
response.send({
12
type: "SUCCESS",
13
url: url.format({
14
protocol: request.protocol,
15
host: request.get("host"),
16
pathname: path.join(request.body.parent, asset.name),
17
}),
18
});
19
});
Copied!
If the user publishes their design as an image, Canva provides each page of the design as a separate asset. Otherwise, Canva provides the entire design as a single asset.
If you'd like to share your app with Canva's users, the url property must point to a page that lets users view their published design in context on the destination platform. For example, if a user publishes a tweet, the URL should point to the page of that tweet.

Example

1
const express = require("express");
2
const jimp = require("jimp");
3
const path = require("path");
4
const url = require("url");
5
const fs = require("fs-extra");
6
7
const app = express();
8
9
app.use(express.json());
10
app.use(express.static("public"));
11
12
app.post("/publish/resources/find", async (request, response) => {
13
const dirPath = request.body.containerId || path.join(__dirname, "public");
14
15
await fs.ensureDir(dirPath);
16
17
const entries = await fs.readdir(dirPath, {
18
withFileTypes: true,
19
});
20
21
const folders = entries
22
.filter((dirent) => dirent.isDirectory())
23
.map((dirent) => {
24
return {
25
type: "CONTAINER",
26
id: path.join(dirPath, dirent.name),
27
name: dirent.name,
28
isOwner: true,
29
readOnly: false,
30
};
31
});
32
33
const files = entries
34
.filter((dirent) => !dirent.isDirectory())
35
.map((dirent) => {
36
return {
37
type: "IMAGE",
38
id: path.join(dirPath, dirent.name),
39
name: dirent.name,
40
isOwner: true,
41
readOnly: false,
42
};
43
});
44
45
response.send({
46
type: "SUCCESS",
47
resources: [...folders, ...files],
48
});
49
});
50
51
app.post("/publish/resources/get", async (request, response) => {
52
const dirPathExists = await fs.pathExists(request.body.id);
53
54
if (!dirPathExists) {
55
response.send({
56
type: "ERROR",
57
errorCode: "NOT_FOUND",
58
});
59
return;
60
}
61
62
response.send({
63
type: "SUCCESS",
64
resource: {
65
type: "CONTAINER",
66
id: request.body.id,
67
name: path.basename(request.body.id),
68
isOwner: true,
69
readOnly: false,
70
},
71
});
72
});
73
74
app.post("/publish/resources/upload", async (request, response) => {
75
const [asset] = request.body.assets;
76
const image = await jimp.read(asset.url);
77
const filePath = path.join(request.body.parent, asset.name);
78
await image.writeAsync(filePath);
79
80
response.send({
81
type: "SUCCESS",
82
url: url.format({
83
protocol: request.protocol,
84
host: request.get("host"),
85
pathname: path.join(request.body.parent, asset.name),
86
}),
87
});
88
});
89
90
app.listen(process.env.PORT || 3000);
Copied!
Last modified 3d ago